From 4165c57f92b90cb5308d812620d54078ee2c9452 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 16:13:40 +0200 Subject: [PATCH 001/384] xtask: check that every panel button routes through a command id (#3495) `cargo xtask panel-commands` scans crates/ui-egui/src (test code skipped) for click handlers -- the block of an `if` whose condition calls .clicked()/.changed()/.dragged() and friends, a local bound from one, or a widget fn taking a Ui and returning bool -- that change the document or session directly: active_mut(), Arc::make_mut on a doc, session.undo/redo/select_layer/add_document/close(), or an assignment to a session field. Lexical, std-only scanner; reports file:line and function. Existing violations sit in a commented allowlist (file + function + reason); stale entries are errors so the list only shrinks. Two today: panels.rs color_picker and color_field assign the foreground/background colour instead of dispatching tools.setColors. Runs from `cargo xtask ci` and as a CI step. Documented in docs/development.md and the AGENTS.md checklist; AUTO-219-8 is partial. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 + AGENTS.md | 1 + docs/development.md | 14 + docs/scorecard.md | 4 +- scorecard/automation.toml | 4 +- xtask/src/main.rs | 7 +- xtask/src/panel_commands.rs | 678 ++++++++++++++++++++++++++++++++++++ 7 files changed, 709 insertions(+), 5 deletions(-) create mode 100644 xtask/src/panel_commands.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3116e3e9410..3dca7be0d23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,12 @@ jobs: - name: Dependency layering run: cargo xtask layers + # AGENTS.md rule 1: shell click handlers dispatch command ids (allowlist in + # xtask/src/panel_commands.rs). Static source scan, so one OS is enough. + - name: Panel buttons route through command ids + if: runner.os == 'Linux' + run: cargo xtask panel-commands + # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, # prefs audit). Static: it reads committed files only, no benchmarks run here. - name: Scorecard up to date diff --git a/AGENTS.md b/AGENTS.md index e44dbe32a87..69427458529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower cargo test -p cargo clippy -p --all-targets -- -D warnings cargo xtask layers +cargo xtask panel-commands # if you touched ui-egui: click handlers dispatch command ids (rule 1) cargo xtask wasm # if you touched L0–L6 cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9) diff --git a/docs/development.md b/docs/development.md index 7d9dac3c7aa..38418749ddc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -262,6 +262,20 @@ derived from UI `tl!` literals, the menu catalog and UI command table, and menu the engine source. Catalog rows are validated for malformed and duplicate keys; unused legacy translations do not affect the denominator. Unregistered locale TSV files are ignored. +## Panel command routing + +Every panel button must dispatch an engine command by id (AGENTS.md rule 1), so the UI, CLI, +control channel and MCP stay equivalent. `cargo xtask panel-commands` (also a step of +`cargo xtask ci` and of CI) scans `crates/ui-egui/src` outside test code for click handlers: the +block of an `if` whose condition calls `.clicked()`, `.changed()`, `.dragged()` and friends, or a +widget function that takes a `Ui` and returns `bool`. Inside one it fails, with file:line, on a +direct document or session change: `active_mut()`, `Arc::make_mut` on a doc, +`session.undo/redo/select_layer/add_document/close(…)` or an assignment to a session field +(`app.session.tools.foreground = …`). Dispatch the command instead (`app.run`, `menus::invoke`). +The scanner is lexical and does not follow calls into helpers. Existing violations are listed in +`ALLOWLIST` in `xtask/src/panel_commands.rs` by file and function, each with its reason; an entry +that matches nothing any more is an error, so the list only shrinks. + ## Testing strategy - **Unit and property tests** in every crate. proptest is used for tile COW, regions and codecs. diff --git a/docs/scorecard.md b/docs/scorecard.md index 3357d5465e5..f189fd9cc61 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 25 | 16 | 2 | | | [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 33 of 154 (target 0) | | [Type](#type) | 1 | 4 | 4 | | -| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | +| [Automation](#automation) | 3 | 3 | 5 | 630 of 630 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -234,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 630 of 630 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 002adc76dbf..872e7fce317 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -63,9 +63,9 @@ note = "No ONNX runtime dependency" [[item]] id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" -status = "missing" +status = "partial" issue = 219 -note = "No such xtask check" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f0164649..17252d6b293 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod corpus_pins; mod i18n_coverage; mod ico; mod layers; +mod panel_commands; mod perf; mod pinned; mod scorecard; @@ -23,8 +24,10 @@ usage: cargo xtask commands: layers enforce the crate dependency layering (plan/architecture.md §3) + panel-commands fail on a shell click handler that changes the document or session directly instead of + dispatching a command id (allowlist: xtask/src/panel_commands.rs) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, layers, panel-commands, wasm (stops at first failure) corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, @@ -53,6 +56,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), + Some("panel-commands") => panel_commands::run(&root()), Some("wasm") => cmd_wasm(), Some("ci") => cmd_ci(), Some("corpus") => corpus::cmd(&rest), @@ -214,6 +218,7 @@ fn cmd_ci() -> Result<(), String> { }), ), ("layers", Box::new(cmd_layers)), + ("panel-commands", Box::new(|| panel_commands::run(&root()))), ("wasm", Box::new(cmd_wasm)), ]; let mut done = Vec::new(); diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs new file mode 100644 index 00000000000..e986512fc88 --- /dev/null +++ b/xtask/src/panel_commands.rs @@ -0,0 +1,678 @@ +//! `cargo xtask panel-commands`: every panel button routes through a command id (AGENTS.md rule 1). +//! +//! The UI, CLI, control channel and MCP stay equivalent only while panels dispatch engine commands +//! by id (`app.run`, `menus::invoke`, `session.execute`, the shell's `UI_COMMANDS`). This check +//! scans the egui shell's source for click handlers that change the document or its history +//! directly instead. +//! +//! It is a lexical scanner, not a type checker (std only, no `syn`): +//! +//! * a **click handler** is the block of an `if` whose condition calls `.clicked()` (or +//! `double_clicked`, `secondary_clicked`, `triple_clicked`, `middle_clicked`, `changed`, +//! `dragged`, `drag_stopped`), reads a local bound by `let x = … .clicked() …;` in the same file, +//! or calls a widget function (one that takes a `Ui` and returns `bool`, like `color_readout`); +//! * inside it, a **direct mutation** is `active_mut()` (the mutable document state), +//! `Arc::make_mut` on a `doc`, `session.undo/redo/select_layer/add_document/close(…)`, or an +//! assignment to a session field (`app.session.tools.foreground = …`); +//! * test code is skipped: `*_tests.rs`, `tests.rs`, files under `tests/` and `#[cfg(test)]` items. +//! +//! Out of scope: preferences (`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and +//! switching the active document. Calls into helper functions are not followed, so a handler that calls a helper which mutates the +//! document passes; the check catches the inline pattern, which is how such shortcuts start. +//! +//! Existing violations are listed in [`ALLOWLIST`] by file and enclosing function, each with the +//! reason it is still there. An entry that no longer matches anything is an error, so the list only +//! shrinks: fix the handler (dispatch a command id), then delete its entry. + +use std::path::{Path, PathBuf}; + +/// The shell source the check scans, relative to the workspace root. +pub const SCAN_ROOT: &str = "crates/ui-egui/src"; + +/// A known violation that is tolerated until its handler routes through a command. +#[derive(Debug, Clone, Copy)] +pub struct Allowed { + /// Path relative to the workspace root, with `/` separators. + pub file: &'static str, + /// Enclosing function of the click handler (`` outside any function). + pub func: &'static str, + /// Why it still mutates directly (and what the fix is). + pub reason: &'static str, +} + +/// Existing click handlers that change the document directly. Shrink only: each entry is a +/// follow-up (route it through an engine command), never a place to park new code. +pub const ALLOWLIST: &[Allowed] = &[ + // Window › Color: the HSB sliders and the hex/RGB readout set the foreground colour by + // assignment, so the change never reaches the journal, action recording or the control + // channel's event log. Fix: dispatch `tools.setColors` (it already exists). + Allowed { + file: "crates/ui-egui/src/panels.rs", + func: "color_picker", + reason: "Color panel sliders and readout assign session.tools.foreground; dispatch tools.setColors", + }, + // The Color panel's colour field and hue strip (and their readout) assign the foreground or + // background colour on every drag frame. Fix: dispatch `tools.setColors`. + Allowed { + file: "crates/ui-egui/src/panels.rs", + func: "color_field", + reason: "Color panel field, hue strip and readout assign session.tools.foreground/background; dispatch tools.setColors", + }, +]; + +/// A click handler that changes the document or its history without a command id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + pub file: String, + pub line: usize, + pub func: String, + pub what: String, +} + +const TRIGGERS: &[&str] = &["clicked", "double_clicked", "secondary_clicked", "triple_clicked", "middle_clicked", "changed", "dragged", "drag_stopped"]; + +/// Session methods that change documents or history behind the command registry's back. +const SESSION_MUTATORS: &[&str] = &["undo", "redo", "select_layer", "add_document", "close"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Kind { + Ident(String), + Punct(char), + /// Literals and lifetimes: never part of a pattern. + Other, +} + +#[derive(Debug, Clone)] +struct Token { + kind: Kind, + line: usize, +} + +impl Token { + fn is_ident(&self, s: &str) -> bool { + matches!(&self.kind, Kind::Ident(i) if i == s) + } + fn is_punct(&self, c: char) -> bool { + self.kind == Kind::Punct(c) + } + fn ident(&self) -> Option<&str> { + match &self.kind { + Kind::Ident(i) => Some(i), + _ => None, + } + } +} + +/// Split Rust source into identifiers and punctuation, dropping comments and literals. +fn lex(src: &str) -> Vec { + let c: Vec = src.chars().collect(); + let at = |i: usize| c.get(i).copied().unwrap_or('\0'); + let mut out = Vec::new(); + let mut line = 1; + let mut i = 0; + while i < c.len() { + let ch = at(i); + if ch == '\n' { + line += 1; + i += 1; + } else if ch.is_whitespace() { + i += 1; + } else if ch == '/' && at(i + 1) == '/' { + while i < c.len() && at(i) != '\n' { + i += 1; + } + } else if ch == '/' && at(i + 1) == '*' { + let mut depth = 0usize; + while i < c.len() { + if at(i) == '/' && at(i + 1) == '*' { + depth += 1; + i += 2; + } else if at(i) == '*' && at(i + 1) == '/' { + depth = depth.saturating_sub(1); + i += 2; + if depth == 0 { + break; + } + } else { + if at(i) == '\n' { + line += 1; + } + i += 1; + } + } + } else if let Some(hashes) = raw_string_start(&c, i) { + // r"…", r#"…"#, br#"…"#: skip to the quote followed by the same number of hashes. + let start_line = line; + i += hashes.prefix; + while i < c.len() { + if at(i) == '"' && (1..=hashes.count).all(|k| at(i + k) == '#') { + i += 1 + hashes.count; + break; + } + if at(i) == '\n' { + line += 1; + } + i += 1; + } + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '"' || (ch == 'b' && at(i + 1) == '"') || (ch == 'c' && at(i + 1) == '"') { + let start_line = line; + i += if ch == '"' { 1 } else { 2 }; + while i < c.len() { + match at(i) { + '\\' => i += 2, + '"' => { + i += 1; + break; + } + '\n' => { + line += 1; + i += 1; + } + _ => i += 1, + } + } + out.push(Token { kind: Kind::Other, line: start_line }); + } else if ch == '\'' || (ch == 'b' && at(i + 1) == '\'') { + // Char literal ('a', '\n', b'x') or lifetime/label ('a, 'static). + let q = if ch == 'b' { i + 1 } else { i }; + if at(q + 1) == '\\' { + i = q + 2; + while i < c.len() && at(i) != '\'' && at(i) != '\n' { + i += 1; + } + i += 1; + } else if at(q + 2) == '\'' { + i = q + 3; + } else { + i = q + 1; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + } + out.push(Token { kind: Kind::Other, line }); + } else if ch.is_alphabetic() || ch == '_' { + let s = i; + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_') { + i += 1; + } + out.push(Token { kind: Kind::Ident(c.get(s..i).unwrap_or_default().iter().collect()), line }); + } else if ch.is_ascii_digit() { + while i < c.len() && (at(i).is_alphanumeric() || at(i) == '_' || (at(i) == '.' && at(i + 1).is_ascii_digit())) { + i += 1; + } + out.push(Token { kind: Kind::Other, line }); + } else { + out.push(Token { kind: Kind::Punct(ch), line }); + i += 1; + } + } + out +} + +struct RawStart { + /// Characters before the body (`r#"` is 3). + prefix: usize, + count: usize, +} + +fn raw_string_start(c: &[char], i: usize) -> Option { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + // Not the tail of an identifier such as `for` or `bar`. + if i > 0 && (at(i - 1).is_alphanumeric() || at(i - 1) == '_') { + return None; + } + let mut j = i; + if at(j) == 'b' || at(j) == 'c' { + j += 1; + } + if at(j) != 'r' { + return None; + } + j += 1; + let mut count = 0; + while at(j) == '#' { + count += 1; + j += 1; + } + (at(j) == '"').then_some(RawStart { prefix: j + 1 - i, count }) +} + +/// Index of the bracket closing the one at `open` (`{`, `(` or `[`), or the last token. +fn matching(toks: &[Token], open: usize) -> usize { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(open) { + match t.kind { + Kind::Punct('{' | '(' | '[') => depth += 1, + Kind::Punct('}' | ')' | ']') => { + depth = depth.saturating_sub(1); + if depth == 0 { + return k; + } + } + _ => {} + } + } + toks.len().saturating_sub(1) +} + +/// First `{` (or `;`) after `from` outside parentheses and brackets. +fn next_block_or_semi(toks: &[Token], from: usize) -> Option { + let mut depth = 0usize; + for (k, t) in toks.iter().enumerate().skip(from) { + match t.kind { + Kind::Punct('(' | '[') => depth += 1, + Kind::Punct(')' | ']') => depth = depth.saturating_sub(1), + Kind::Punct('{' | ';') if depth == 0 => return Some(k), + _ => {} + } + } + None +} + +/// Drop every item under `#[cfg(test)]` (or a `cfg(all(test, …))`), keeping `cfg(not(test))`. +fn strip_cfg_test(toks: Vec) -> Vec { + let mut out = Vec::with_capacity(toks.len()); + let mut i = 0; + while i < toks.len() { + if is_test_attr(&toks, i) { + // Skip this attribute, any further attributes, then the item itself. + let mut k = i; + while toks.get(k).is_some_and(|t| t.is_punct('#')) && toks.get(k + 1).is_some_and(|t| t.is_punct('[')) { + k = matching(&toks, k + 1) + 1; + } + i = match next_block_or_semi(&toks, k) { + Some(e) if toks.get(e).is_some_and(|t| t.is_punct('{')) => matching(&toks, e) + 1, + Some(e) => e + 1, + None => toks.len(), + }; + continue; + } + if let Some(t) = toks.get(i) { + out.push(t.clone()); + } + i += 1; + } + out +} + +fn is_test_attr(toks: &[Token], i: usize) -> bool { + let (Some(hash), Some(open), Some(cfg)) = (toks.get(i), toks.get(i + 1), toks.get(i + 2)) else { return false }; + if !hash.is_punct('#') || !open.is_punct('[') || !cfg.is_ident("cfg") { + return false; + } + let close = matching(toks, i + 1); + let inner = toks.get(i + 3..close).unwrap_or_default(); + inner.iter().any(|t| t.is_ident("test")) && !inner.iter().any(|t| t.is_ident("not")) +} + +/// `(body open, body close, name)` of every function with a body. +fn functions(toks: &[Token]) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + if let Some(open) = next_block_or_semi(toks, i + 2).filter(|&o| toks.get(o).is_some_and(|t| t.is_punct('{'))) { + out.push((open, matching(toks, open), name.to_string())); + } + } + out +} + +/// Does `toks` call `.trigger(` for one of the click triggers? +fn calls_trigger(toks: &[Token]) -> bool { + toks.windows(3).any(|w| { + w.first().is_some_and(|t| t.is_punct('.')) + && w.get(1).and_then(Token::ident).is_some_and(|n| TRIGGERS.contains(&n)) + && w.get(2).is_some_and(|t| t.is_punct('(')) + }) +} + +/// Locals bound by `let name = … .clicked() …;`. +fn click_locals(toks: &[Token]) -> Vec { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("let") { + continue; + } + let mut n = i + 1; + if toks.get(n).is_some_and(|t| t.is_ident("mut")) { + n += 1; + } + let (Some(name), Some(eq)) = (toks.get(n).and_then(Token::ident), toks.get(n + 1)) else { continue }; + if !eq.is_punct('=') { + continue; + } + let Some(end) = next_block_or_semi(toks, n + 2) else { continue }; + // A `{` before the `;` (a block or closure value) is out of scope for this heuristic. + if toks.get(end).is_some_and(|t| t.is_punct(';')) && calls_trigger(toks.get(n + 2..end).unwrap_or_default()) { + out.push(name.to_string()); + } + } + out +} + +/// Direct document/history mutations in `toks`: `(token index, description)`. +fn mutations(toks: &[Token]) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + let next = |k: usize| toks.get(i + k); + let Some(name) = t.ident() else { continue }; + let call = next(1).is_some_and(|t| t.is_punct('(')); + if name == "active_mut" && call && i > 0 && toks.get(i - 1).is_some_and(|p| p.is_punct('.')) { + out.push((i, "`active_mut()` changes the document directly".to_string())); + } else if name == "make_mut" && call { + let close = matching(toks, i + 1); + let args = toks.get(i + 2..close).unwrap_or_default(); + if args.iter().any(|a| a.ident().is_some_and(|n| n == "doc" || n.ends_with("_doc") || n == "document")) { + out.push((i, "`Arc::make_mut` on a document changes it directly".to_string())); + } + } else if name == "session" + && next(1).is_some_and(|t| t.is_punct('.')) + && let Some(m) = next(2).and_then(Token::ident).filter(|m| SESSION_MUTATORS.contains(m)) + && next(3).is_some_and(|t| t.is_punct('(')) + { + out.push((i, format!("`session.{m}()` bypasses the command registry"))); + } else if name == "session" + && let Some(field) = assigned_field(toks, i) + { + out.push((i, format!("`session.{field} = …` changes session state directly"))); + } + } + out +} + +/// `a.b` when `toks[i]` (`session`) starts `session.a.b = …` or a compound assignment (`+=`, …). +fn assigned_field(toks: &[Token], i: usize) -> Option { + let mut path = Vec::new(); + let mut k = i + 1; + while toks.get(k).is_some_and(|t| t.is_punct('.')) { + path.push(toks.get(k + 1)?.ident()?.to_string()); + k += 2; + } + let first = toks.get(k)?; + let second = toks.get(k + 1); + let plain = first.is_punct('=') && !second.is_some_and(|t| t.is_punct('=') || t.is_punct('>')); + let compound = matches!(first.kind, Kind::Punct('+' | '-' | '*' | '/' | '|' | '&' | '^')) && second.is_some_and(|t| t.is_punct('=')); + (!path.is_empty() && (plain || compound)).then(|| path.join(".")) +} + +/// Names of functions that draw a widget and report an edit: they take a `Ui` and return `bool` +/// (`fn color_readout(ui: &mut Ui, …) -> bool`). `if widget(…) { … }` is a click handler too. +pub fn widget_fns(src: &str) -> Vec { + let toks = strip_cfg_test(lex(src)); + let mut out = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("fn") { + continue; + } + let Some(name) = toks.get(i + 1).and_then(Token::ident) else { continue }; + let Some(open) = toks.iter().enumerate().skip(i + 2).find(|(_, t)| t.is_punct('(')).map(|(k, _)| k) else { continue }; + let close = matching(&toks, open); + let takes_ui = toks.get(open..close).unwrap_or_default().iter().any(|t| t.is_ident("Ui")); + let returns_bool = toks.get(close + 1).is_some_and(|t| t.is_punct('-')) + && toks.get(close + 2).is_some_and(|t| t.is_punct('>')) + && toks.get(close + 3).is_some_and(|t| t.is_ident("bool")); + if takes_ui && returns_bool && !out.iter().any(|n| n == name) { + out.push(name.to_string()); + } + } + out +} + +/// Every click handler in `src` that changes the document directly. `file` labels the results. +/// `widgets` lists the widget functions of the whole tree (see [`widget_fns`]). +pub fn scan(file: &str, src: &str, widgets: &[String]) -> Vec { + let toks = strip_cfg_test(lex(src)); + let fns = functions(&toks); + let locals = click_locals(&toks); + let mut out: Vec = Vec::new(); + for (i, t) in toks.iter().enumerate() { + if !t.is_ident("if") { + continue; + } + let Some(open) = next_block_or_semi(&toks, i + 1) else { continue }; + if !toks.get(open).is_some_and(|t| t.is_punct('{')) { + continue; + } + let cond = toks.get(i + 1..open).unwrap_or_default(); + let is_click = calls_trigger(cond) + || cond.iter().any(|c| c.ident().is_some_and(|n| locals.iter().any(|l| l == n))) + || cond + .windows(2) + .any(|w| w.first().and_then(Token::ident).is_some_and(|n| widgets.iter().any(|f| f == n)) && w.get(1).is_some_and(|t| t.is_punct('('))); + if !is_click { + continue; + } + let close = matching(&toks, open); + let body = toks.get(open..=close).unwrap_or_default(); + for (k, what) in mutations(body) { + let at = open + k; + let line = toks.get(at).map_or(0, |t| t.line); + let func = fns.iter().filter(|(o, c, _)| *o < at && at < *c).min_by_key(|(o, c, _)| c - o).map_or("", |f| f.2.as_str()); + let v = Violation { file: file.to_string(), line, func: func.to_string(), what }; + if !out.contains(&v) { + out.push(v); + } + } + } + out.sort_by_key(|v| v.line); + out +} + +/// Is `path` (relative, `/`-separated) test code? +fn is_test_file(path: &str) -> bool { + path.ends_with("_tests.rs") || path.ends_with("/tests.rs") || path == "tests.rs" || path.contains("/tests/") || path.starts_with("tests/") +} + +/// Violations not covered by `allow`, and allowlist entries that match nothing (stale). +pub fn check<'a>(violations: &[Violation], allow: &'a [Allowed]) -> (Vec, Vec<&'a Allowed>) { + let covered = |v: &Violation, a: &Allowed| v.file == a.file && v.func == a.func; + let new = violations.iter().filter(|v| !allow.iter().any(|a| covered(v, a))).cloned().collect(); + let stale = allow.iter().filter(|a| !violations.iter().any(|v| covered(v, a))).collect(); + (new, stale) +} + +fn rust_files(dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for e in entries { + let p = e.map_err(|e| format!("{}: {e}", dir.display()))?.path(); + if p.is_dir() { + rust_files(&p, out)?; + } else if p.extension().is_some_and(|x| x == "rs") { + out.push(p); + } + } + Ok(()) +} + +/// Scan the shell source under `root` (the workspace root). +pub fn scan_tree(root: &Path) -> Result, String> { + let base = root.join(SCAN_ROOT); + let mut files = Vec::new(); + rust_files(&base, &mut files)?; + files.sort(); + let mut sources = Vec::new(); + for f in files { + let rel = f.strip_prefix(&base).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/"); + if is_test_file(&rel) { + continue; + } + let src = std::fs::read_to_string(&f).map_err(|e| format!("{}: {e}", f.display()))?; + sources.push((format!("{SCAN_ROOT}/{rel}"), src)); + } + let mut widgets = Vec::new(); + for (_, src) in &sources { + for name in widget_fns(src) { + if !widgets.contains(&name) { + widgets.push(name); + } + } + } + Ok(sources.iter().flat_map(|(file, src)| scan(file, src, &widgets)).collect()) +} + +pub fn run(root: &Path) -> Result<(), String> { + let violations = scan_tree(root)?; + let (new, stale) = check(&violations, ALLOWLIST); + println!("Panel command routing (AGENTS.md rule 1): click handlers in {SCAN_ROOT} must dispatch a command id\n"); + println!("{} direct mutation(s) in click handlers, {} allowlisted function(s)", violations.len(), ALLOWLIST.len()); + for v in &new { + println!(" {}:{}: in `{}`: {}; dispatch an engine command by id instead", v.file, v.line, v.func, v.what); + } + for a in ALLOWLIST.iter().filter(|a| !stale.iter().any(|s| s.file == a.file && s.func == a.func)) { + println!(" allowlisted {} `{}`: {}", a.file, a.func, a.reason); + } + for a in &stale { + println!(" stale allowlist entry {} `{}`: no direct mutation left there; delete it from xtask/src/panel_commands.rs", a.file, a.func); + } + if new.is_empty() && stale.is_empty() { + println!("OK: every other click handler routes through a command id."); + Ok(()) + } else { + Err(format!("{} unrouted click handler(s), {} stale allowlist entr(y/ies)", new.len(), stale.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(src: &str) -> Vec<(usize, String)> { + scan("f.rs", src, &[]).into_iter().map(|v| (v.line, v.func)).collect() + } + + #[test] + fn routed_button_passes() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Flatten").clicked() { + let _ = app.run("layer.flatten", json!({})); + } + if ui.button("Undo").clicked() { + menus::invoke(app, ctx, "edit.undo", Value::Null); + } +} +"#; + assert!(scan("f.rs", src, &[]).is_empty()); + } + + #[test] + fn direct_mutation_fails_with_file_line_and_function() { + let src = r#" +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("Hide").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"#; + let v = scan("crates/ui-egui/src/x.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!((v[0].file.as_str(), v[0].line, v[0].func.as_str()), ("crates/ui-egui/src/x.rs", 4, "panel")); + assert!(v[0].what.contains("active_mut")); + } + + #[test] + fn every_mutation_kind_and_trigger_is_caught() { + let src = r#" +fn a(app: &mut App, ui: &mut Ui) { + if ui.button("x").secondary_clicked() { app.session.undo(); } + if resp.double_clicked() && ok { Arc::make_mut(&mut st.doc).layers.clear(); } + let pressed = ui.button("y").clicked(); + if pressed { app.session.close(0); } + if slider.changed() { app.session.select_layer(id); } +} +"#; + assert_eq!(lines(src), vec![(3, "a".into()), (4, "a".into()), (6, "a".into()), (7, "a".into())]); + } + + #[test] + fn mutation_outside_click_handlers_test_code_comments_and_strings_pass() { + let src = r##" +fn sync(app: &mut App) { + if let Some(st) = app.session.active_mut() { st.dirty = true; } +} +fn panel(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { + // app.session.active_mut() + let s = "app.session.undo()"; + let r = r#"session.close(0)"#; + let c = '{'; + let _ = app.run("edit.undo", Value::Null); + } + if ui.button("y").clicked() { theme(Arc::make_mut(&mut fonts)); } +} +#[cfg(test)] +mod tests { + fn t(app: &mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.undo(); } + } +} +"##; + assert!(scan("f.rs", src, &[]).is_empty(), "{:?}", scan("f.rs", src, &[])); + } + + #[test] + fn widget_functions_and_session_assignments_count() { + let widgets = widget_fns("fn color_readout(ui: &mut egui::Ui, c: &mut [f32; 4]) -> bool { false }\nfn label(ui: &mut Ui) {}"); + assert_eq!(widgets, vec!["color_readout".to_string()]); + let src = r#" +fn field(app: &mut App, ui: &mut Ui) { + if color_readout(ui, &mut c) { app.session.tools.foreground = c; } + if r.dragged() { app.session.tools.brush.size += 1.0; } + if r.clicked() { let same = app.session.tools.foreground == c; app.ui.show = true; } + if r.clicked() { let _ = app.run("tools.setColors", json!({"foreground": "red"})); } +} +"#; + let v = scan("f.rs", src, &widgets); + assert_eq!(v.iter().map(|v| v.line).collect::>(), vec![3, 4]); + assert!(v[0].what.contains("session.tools.foreground"), "{}", v[0].what); + assert!(v[1].what.contains("session.tools.brush.size"), "{}", v[1].what); + } + + #[test] + fn cfg_not_test_is_scanned_and_lifetimes_do_not_confuse_the_lexer() { + let src = r#" +#[cfg(not(test))] +fn panel<'a>(app: &'a mut App, ui: &mut Ui) { + if ui.button("x").clicked() { app.session.redo(); } +} +"#; + assert_eq!(lines(src), vec![(4, "panel".into())]); + } + + #[test] + fn allowlisted_passes_and_stale_entry_is_reported() { + let v = vec![Violation { file: "a.rs".into(), line: 3, func: "panel".into(), what: "x".into() }]; + let allow = [Allowed { file: "a.rs", func: "panel", reason: "known" }, Allowed { file: "a.rs", func: "gone", reason: "fixed already" }]; + let (new, stale) = check(&v, &allow); + assert!(new.is_empty()); + assert_eq!(stale.len(), 1); + assert_eq!(stale[0].func, "gone"); + let (new, stale) = check(&v, &[]); + assert_eq!(new, v); + assert!(stale.is_empty()); + } + + #[test] + fn test_files_are_recognised() { + assert!(is_test_file("panels_tests.rs")); + assert!(is_test_file("file_dialog/tests.rs")); + assert!(is_test_file("discard_ui/tests/tiff_tests.rs")); + assert!(!is_test_file("panels.rs")); + assert!(!is_test_file("tests_ui.rs")); + } + + #[test] + fn the_allowlist_has_reasons_and_no_duplicates() { + for (i, a) in ALLOWLIST.iter().enumerate() { + assert!(!a.reason.trim().is_empty(), "{} `{}` needs a reason", a.file, a.func); + assert!(a.file.starts_with(SCAN_ROOT), "{} is outside {SCAN_ROOT}", a.file); + assert!(!ALLOWLIST.iter().skip(i + 1).any(|b| b.file == a.file && b.func == a.func), "duplicate {} `{}`", a.file, a.func); + } + } +} From d71e6dab0f5b0f28da0fb4cddd7295f022f62f56 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:42:55 +0200 Subject: [PATCH 002/384] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index d2ee641b1e2..350733493b4 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -234,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | From 6ceb0dbae22e81efec2856a75fa663e2f501738c Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 14:06:39 +0200 Subject: [PATCH 003/384] panel-commands: follow main's move of the HSB sliders into hsb_sliders Main split the Color panel's HSB sliders out of color_picker (#4399), so the allowlist entry moves with them; regenerate docs/scorecard.md after the merge. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- scorecard/automation.toml | 2 +- xtask/src/panel_commands.rs | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1ad3f3aa3d3..5008607ec13 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 3 | 2 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -234,7 +234,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index e0f977d2103..32d1d5fcf22 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -65,7 +65,7 @@ id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" status = "partial" issue = 219 -note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs color_picker, color_field assign the foreground/background colour instead of tools.setColors)" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index e986512fc88..ec76ff9f867 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -43,13 +43,13 @@ pub struct Allowed { /// Existing click handlers that change the document directly. Shrink only: each entry is a /// follow-up (route it through an engine command), never a place to park new code. pub const ALLOWLIST: &[Allowed] = &[ - // Window › Color: the HSB sliders and the hex/RGB readout set the foreground colour by - // assignment, so the change never reaches the journal, action recording or the control - // channel's event log. Fix: dispatch `tools.setColors` (it already exists). + // Window › Color: the HSB sliders set the foreground colour by assignment, so the change + // never reaches the journal, action recording or the control channel's event log. + // Fix: dispatch `tools.setColors` (it already exists). Allowed { file: "crates/ui-egui/src/panels.rs", - func: "color_picker", - reason: "Color panel sliders and readout assign session.tools.foreground; dispatch tools.setColors", + func: "hsb_sliders", + reason: "Color panel HSB sliders assign session.tools.foreground; dispatch tools.setColors", }, // The Color panel's colour field and hue strip (and their readout) assign the foreground or // background colour on every drag frame. Fix: dispatch `tools.setColors`. From 02fff63cb4b8a439dab8a3bd53376f96367ee991 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:55:47 +0300 Subject: [PATCH 004/384] Brush Settings: combine the Spacing checkbox and percentage header Keep the saved spacing while Off or toggling, and retain percentage limits and tools.setBrush routing. Cover the shared header in all themes and add disabled-input, value-retention and journal regressions. Addresses only the Spacing header subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel_tests.rs | 41 +++++++++++++ crates/ui-egui/src/brush_sections.rs | 78 +++++++++++++++++++++++-- docs/brush-parity.md | 4 +- docs/gaps.md | 7 ++- 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d55065..14fa1e40bae 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,47 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn spacing_row_edits_route_only_the_changed_fields_through_the_brush_command() { + use egui_kittest::kittest::{NodeT, Queryable}; + + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"spacing": 0.427, "spacingEnabled": false}})).unwrap(); + h.run_steps(2); + let journal = h.state().session.journal.len(); + let spacing = h.state().session.tools.brush.spacing; + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert!(h.state().session.tools.brush.spacing_enabled); + assert_eq!(h.state().session.tools.brush.spacing, spacing); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": true})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacing": 0.44_f32})); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"spacingEnabled": false})); + let field = h.query_all_by_role(egui::accesskit::Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(44.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 3, "disabled input adds no command"); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.spacing, 0.44); + assert_eq!(h.state().session.journal.len(), journal + 4); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d2928..4b6a1ea6fb3 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -53,6 +53,23 @@ fn signed_pct(ui: &mut egui::Ui, label: &str, v: &mut f32, min: f32, max: f32) - changed } +/// The checkbox labels the spacing field; toggling it must retain imported fractional values. +fn spacing_row(ui: &mut egui::Ui, b: &mut BrushSettings) { + let mut percent = (b.spacing * 100.0).round(); + let mut changed = false; + ui.horizontal(|ui| { + widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::value_field(ui, &mut percent, 1.0..=1000.0, "%", 74.0)).inner.changed(); + }); + }); + changed |= ui.add_enabled_ui(b.spacing_enabled, |ui| widgets::slider(ui, &mut percent, 1.0..=1000.0, None)).inner.changed(); + if changed { + b.spacing = (percent.round() / 100.0).clamp(0.01, 10.0); + } + ui.add_space(4.0); +} + fn num(ui: &mut egui::Ui, label: &str, v: &mut f32, range: std::ops::RangeInclusive, suffix: &str) -> bool { let mut x = *v; let changed = widgets::slider_row(ui, label, &mut x, range.clone(), suffix, None).changed(); @@ -325,11 +342,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { pct(ui, tl!("Hardness"), &mut b.hardness, 1.0); }); - // Unchecked, the pointer's speed sets the spacing (Photoshop). - widgets::checkbox(ui, &mut b.spacing_enabled, tl!("Spacing")).on_hover_text(tl!("Off: the speed of the pointer sets the spacing")); - ui.add_space(2.0); - let on = b.spacing_enabled; - ui.add_enabled_ui(on, |ui| signed_pct(ui, tl!("Spacing"), &mut b.spacing, 0.01, 10.0)); + spacing_row(ui, b); None } @@ -645,6 +658,61 @@ mod tests { h } + #[test] + fn spacing_row_has_one_label_and_a_shared_header_in_every_theme() { + for kind in theme::ThemeKind::ALL { + for enabled in [false, true] { + let b = BrushSettings { spacing: 0.427, spacing_enabled: enabled, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + assert_eq!(h.query_all_by_label("Spacing").count(), 1, "{kind:?}: the checkbox is the single label"); + let checkbox = h.get_by_label("Spacing").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(43.0)).unwrap(); + assert!((checkbox.center().y - field.rect().center().y).abs() < 2.0, "{kind:?}: checkbox and percentage share a row"); + assert_eq!(field.accesskit_node().is_disabled(), !enabled); + assert_eq!(h.state(), &b, "rendering does not round the saved spacing"); + } + } + } + + #[test] + fn spacing_row_retains_disabled_values_and_percentage_editing_limits() { + let mut h = section_harness(0, BrushSettings { spacing: 0.42, spacing_enabled: false, ..Default::default() }); + let before = h.state().clone(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.run_steps(2); + drag(&mut h, from, to); + assert_eq!(h.state(), &before, "Off ignores both field input and a track drag"); + let checkbox = h.query_all_by_label("Spacing").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + checkbox.click(); + h.run_steps(2); + assert!(h.state().spacing_enabled); + assert_eq!(h.state().spacing, 0.42, "enabling keeps the saved value"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(42.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(h.state().spacing, 0.43, "integer percentage edits become a fraction"); + drag(&mut h, from, to); + assert!(h.state().spacing > 5.0, "the same track is editable when enabled"); + for (spacing, value, key) in [(0.01, 1.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + h.state_mut().spacing = spacing; + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state().spacing, spacing, "the percentage stays within 1..1000"); + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e4998207..6585b533e41 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Spacing checkbox and percentage share one header; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -19,6 +19,7 @@ tablets against Photoshop side by side. | Section | Status | Evidence | |---|---|---| | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Brush Tip Shape Spacing checkbox and percentage share one header without a duplicated label | done | #3825 (Spacing header only); UI regressions cover all 14 themes, enabled/disabled editing, percentage limits, fractional-value retention and `tools.setBrush` routing. | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Brush Tip Shape combines the Spacing checkbox and percentage into one header (#3825, partial), retaining the value while disabled. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab85..a3d116ee9b7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: combined Spacing header, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape's Spacing checkbox and + percentage share one header, removing the duplicated label. Disabled controls retain + the saved spacing; toggling does not round fractional imported values. The issue's + remaining layout and control gaps stay open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -264,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: combined Spacing checkbox/percentage header (#3825, partial); disabled-value and percentage-edit behavior retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | From fe083602a4fcff30159257c89af699f561bbaef3 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:29:15 +0300 Subject: [PATCH 005/384] Brush Settings: place Both Axes on the Scatter header Share the header layout between Scattering and Dual Brush, retaining label scrubbing, percentage limits, fractional values and tools.setBrush routing. Cover layout in all themes and guard disabled input and independent brush blocks. Addresses only the Both Axes placement subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel_tests.rs | 63 ++++++++++++ crates/ui-egui/src/brush_sections.rs | 126 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 3 +- docs/brush-parity.md | 4 +- docs/gaps.md | 7 +- 5 files changed, 196 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d55065..94cc3e4f4b6 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,69 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn scatter_header_edits_route_to_the_correct_brush_block_and_disabled_sections_ignore_input() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + + for section in [2, 4] { + let block = if section == 2 { "scattering" } else { "dualBrush" }; + let mut h = harness(0, section); + let mut initial = json!({}); + initial[block] = if section == 2 { + json!({"enabled": true, "bothAxes": false, "scatter": {"jitter": 2.137, "control": "penPressure"}, "count": 5}) + } else { + json!({"enabled": true, "bothAxes": false, "scatter": 2.137, "count": 7}) + }; + h.state_mut().run("tools.setBrush", json!({"brush": initial})).unwrap(); + h.run_steps(3); + let journal = h.state().session.journal.len(); + let before = h.state().session.tools.brush.clone(); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + let mut expected = json!({}); + expected[block] = json!({"bothAxes": true}); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], expected); + let mut after = before; + if section == 2 { + after.scattering.both_axes = true; + } else { + after.dual_brush.both_axes = true; + } + assert_eq!(h.state().session.tools.brush, after, "toggle changes only the matching axis flag"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected[block] = if section == 2 { json!({"scatter": {"jitter": 2.15_f32}}) } else { json!({"scatter": 2.15_f32}) }; + assert_eq!(last_journal(h.state()).unwrap().1["brush"], expected); + if section == 2 { + after.scattering.scatter.jitter = 2.15; + } else { + after.dual_brush.scatter = 2.15; + } + assert_eq!(h.state().session.tools.brush, after); + assert_eq!(h.state().session.journal.len(), journal + 2); + let mut off = json!({}); + off[block] = json!({"enabled": false}); + h.state_mut().run("tools.setBrush", json!({"brush": off})).unwrap(); + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(215.0)).unwrap(); + assert!(field.accesskit_node().is_disabled()); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.event(egui::Event::Text("999".into())); + h.key_press(egui::Key::Enter); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush, before, "the disabled header retains both values"); + assert_eq!(h.state().session.journal.len(), journal, "disabled edits add no command"); + } +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d2928..c79ec8d7ed8 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -353,10 +353,29 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) { widgets::checkbox(ui, &mut sd.brush_projection, tl!("Brush Projection")).on_hover_text(tl!("Apply the pen's tilt and rotation to the tip shape")); } +/// Both scatter controls put their axis switch between the label and percentage field. +fn scatter_row(ui: &mut egui::Ui, amount: &mut f32, both_axes: &mut bool) { + let t = Tokens::get(ui.ctx()); + let mut percent = (*amount * 100.0).round(); + let mut changed = false; + ui.horizontal(|ui| { + let label = ui.label(RichText::new(tl!("Scatter")).color(t.text_dim)); + widgets::scrub_label_within(ui, &label, f32::INFINITY); + widgets::checkbox(ui, both_axes, tl!("Both Axes")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed = widgets::value_field(ui, &mut percent, 0.0..=1000.0, "%", 74.0).changed(); + }); + }); + changed |= widgets::slider(ui, &mut percent, 0.0..=1000.0, None).changed(); + if changed { + *amount = (percent.round() / 100.0).clamp(0.0, 10.0); + } + ui.add_space(4.0); +} + fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) { let sc = &mut b.scattering; - pct(ui, tl!("Scatter"), &mut sc.scatter.jitter, 10.0); - widgets::checkbox(ui, &mut sc.both_axes, tl!("Both Axes")); + scatter_row(ui, &mut sc.scatter.jitter, &mut sc.both_axes); ui.add_space(2.0); control_row(ui, "brush-scatter-ctl", &mut sc.scatter, false); ui.add_space(6.0); @@ -498,8 +517,7 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) let orig = d.tip.bitmap_size().map(|(w, h)| w.max(h) as f32); size_row(ui, tl!("Size"), &mut d.size, 2500.0, orig); signed_pct(ui, tl!("Spacing"), &mut d.spacing, 0.01, 10.0); - pct(ui, tl!("Scatter"), &mut d.scatter, 10.0); - widgets::checkbox(ui, &mut d.both_axes, tl!("Both Axes")); + scatter_row(ui, &mut d.scatter, &mut d.both_axes); ui.add_space(4.0); let mut c = d.count as f32; if num(ui, tl!("Count"), &mut c, 1.0..=16.0, "") { @@ -645,6 +663,106 @@ mod tests { h } + fn check_scatter_header(section: usize) { + for kind in theme::ThemeKind::ALL { + for both_axes in [false, true] { + let mut b = BrushSettings::default(); + b.scattering.scatter.jitter = 2.137; + b.scattering.both_axes = both_axes; + b.dual_brush.scatter = 2.137; + b.dual_brush.both_axes = both_axes; + let mut h = section_harness(section, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let label = h.get_by_label("Scatter").rect(); + let axes = h.get_by_label("Both Axes").rect(); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap().rect(); + assert!((label.center().y - axes.center().y).abs() < 2.0, "{kind:?}, section {section}: axes belongs on the Scatter header"); + assert!((axes.center().y - field.center().y).abs() < 2.0, "{kind:?}, section {section}: axes and percentage share a row"); + assert!(label.right() < axes.left() && axes.right() < field.left(), "{kind:?}, section {section}: label, checkbox, percentage order"); + assert_eq!(h.state(), &b, "rendering retains fractional scatter and other settings"); + } + } + } + + #[test] + fn scatter_header_scattering_places_axes_between_label_and_percentage_in_all_themes() { + check_scatter_header(2); + } + + #[test] + fn scatter_header_dual_brush_places_axes_between_label_and_percentage_in_all_themes() { + check_scatter_header(4); + } + + #[test] + fn scatter_header_preserves_values_percentage_limits_and_label_scrubbing() { + for section in [2, 4] { + let mut b = BrushSettings::default(); + b.scattering.scatter.jitter = 2.137; + b.scattering.scatter.control = Control::PenPressure; + b.scattering.count = 5; + b.dual_brush.scatter = 2.137; + b.dual_brush.count = 7; + let mut h = section_harness(section, b.clone()); + h.get_by_label("Both Axes").click(); + h.run_steps(2); + if section == 2 { + b.scattering.both_axes = true; + } else { + b.dual_brush.both_axes = true; + } + assert_eq!(h.state(), &b, "a toggle does not round scatter or change unrelated fields"); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(214.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + if section == 2 { + b.scattering.scatter.jitter = 2.15; + } else { + b.dual_brush.scatter = 2.15; + } + assert_eq!(h.state(), &b, "percentage edits preserve the other brush block"); + let label = h.get_by_label("Scatter").rect().center(); + drag(&mut h, label, label + vec2(18.0, 0.0)); + let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; + assert!(amount > 2.15, "the label still scrubs the percentage across the checkbox"); + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + assert_eq!(h.state(), &b, "scrubbing changes only the scatter amount"); + let field = + h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(f64::from((amount * 100.0).round()))).unwrap(); + let from = pos2(field.rect().left() - 100.0, field.rect().bottom() + 9.0); + let to = pos2(field.rect().right() - 25.0, from.y); + drag(&mut h, from, to); + let amount = if section == 2 { h.state().scattering.scatter.jitter } else { h.state().dual_brush.scatter }; + assert!(amount > 5.0, "the existing amount track remains editable"); + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + assert_eq!(h.state(), &b); + for (amount, value, key) in [(0.0, 0.0, egui::Key::ArrowDown), (10.0, 1000.0, egui::Key::ArrowUp)] { + if section == 2 { + b.scattering.scatter.jitter = amount; + } else { + b.dual_brush.scatter = amount; + } + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|node| node.accesskit_node().numeric_value() == Some(value)).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "the percentage stays within 0..1000"); + } + } + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9e56022cefc..9850453c774 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -400,7 +400,8 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { scrub_label_within(ui, label, 24.0) } -fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { +// Shared headers may put a control between the label and its field. +pub(crate) fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { // Drag only, not focusable: Tab goes to the value field, not to its label as well. let resp = label.interact(Sense::DRAG); // Whether a field took this label's drags last frame: only then is it scrubby. diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e4998207..ed11de5bf0b 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Both Axes on Scatter headers; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -21,6 +21,7 @@ tablets against Photoshop side by side. | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | +| Scattering and Dual Brush place Both Axes between the Scatter label and percentage | done | #3825 (Both Axes header placement only); all-theme UI regressions cover both axis values, fractional-value retention, percentage limits, label scrubbing, amount-track edits and existing command routing. | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | | Dual Brush | done | | | Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Scattering and Dual Brush place Both Axes on the shared Scatter header (#3825, partial), preserving label scrubbing and values. No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab85..da01374bc6e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Both Axes on Scatter headers, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Scattering and Dual Brush place Both Axes + between the Scatter label and percentage field. The existing amount track, label + scrubbing, percentage limits and disabled-section value retention are preserved. + The assessment's remaining layout and control gaps stay open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -264,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: Scattering and Dual Brush Both Axes header placement (#3825, partial); label scrubbing and value retention preserved; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | From 17f8f80b8ca04ded8277685a27f2455074ae7bad Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:41:12 +0300 Subject: [PATCH 006/384] Brush Settings: align Angle and Roundness left of the tip widget Put the numeric controls in a right-aligned left column and keep the interactive ellipse on the right. Preserve units, limits, imported values and existing field/drag command routing. Addresses only the Angle/Roundness layout subsection of #3825. Co-authored-by: GPT-6 --- crates/ui-egui/src/brush_panel_tests.rs | 45 ++++++++ crates/ui-egui/src/brush_sections.rs | 137 +++++++++++++++++++++--- docs/brush-parity.md | 4 +- docs/gaps.md | 7 +- 4 files changed, 177 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 750a9d55065..ddfaebe3e80 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -547,6 +547,51 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +#[test] +fn tip_geometry_edits_and_arrow_drag_route_only_changed_fields_through_the_brush_command() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + let mut h = harness(0, 0); + h.state_mut().run("tools.setBrush", json!({"brush": {"angle": -23.0, "roundness": 0.473, "hardness": 0.65, "spacing": 0.31, "flipX": true}})).unwrap(); + h.run_steps(3); + let mut expected = h.state().session.tools.brush.clone(); + let journal = h.state().session.journal.len(); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.angle = -22.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": -22.0_f32})); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + field.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + expected.roundness = 0.48; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"roundness": 0.48_f32})); + let c = h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap(); + let a = expected.angle.to_radians(); + h.drag_at(c + vec2(a.cos(), -a.sin()) * 35.0); + h.run_steps(1); + h.hover_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + h.drop_at(c + vec2(0.0, -35.0)); + h.run_steps(2); + expected.angle = 90.0; + assert_eq!(h.state().session.tools.brush, expected); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"angle": 90.0_f32})); + assert_eq!(h.state().session.journal.len(), journal + 3, "one command per field edit and arrow gesture"); +} + fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); let path = match section { diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index ff7ca5d2928..d801985c647 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -300,26 +300,31 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], }); ui.add_space(4.0); ui.horizontal(|ui| { - ellipse_widget(ui, &mut b.angle, &mut b.roundness); - ui.add_space(8.0); - ui.vertical(|ui| { + // Reserve the widget's column so both numeric rows share the same right edge. + let fields_width = (ui.available_width() - 84.0 - ui.spacing().item_spacing.x).max(0.0); + ui.allocate_ui_with_layout(vec2(fields_width, 84.0), egui::Layout::top_down(egui::Align::Max), |ui| { ui.add_space(10.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); - let mut a = b.angle; - if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).changed() { - b.angle = a.round().clamp(-180.0, 180.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut a = b.angle; + if widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 70.0).changed() { + b.angle = a.round().clamp(-180.0, 180.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Angle:"))); + }); }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); - let mut r = (b.roundness * 100.0).round(); - if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).changed() { - b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); - } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut r = (b.roundness * 100.0).round(); + if widgets::value_field(ui, &mut r, 1.0..=100.0, "%", 70.0).changed() { + b.roundness = (r.round() / 100.0).clamp(0.01, 1.0); + } + ui.allocate_ui_with_layout(vec2(72.0, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| dim(ui, tl!("Roundness:"))); + }); }); }); + ellipse_widget(ui, &mut b.angle, &mut b.roundness); }); ui.add_space(6.0); ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| { @@ -629,8 +634,12 @@ mod tests { } fn section_harness(section: usize, b: BrushSettings) -> Harness<'static, BrushSettings> { + section_harness_width(section, 340.0, b) + } + + fn section_harness_width(section: usize, width: f32, b: BrushSettings) -> Harness<'static, BrushSettings> { let mut ready = false; - let mut h = Harness::builder().with_size(vec2(340.0, 1100.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(width, 1100.0)).build_ui_state( move |ui, b: &mut BrushSettings| { if !ready { crate::PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); @@ -645,6 +654,106 @@ mod tests { h } + fn tip_geometry_center(h: &Harness<'static, BrushSettings>) -> egui::Pos2 { + fn centre(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(c) if c.radius == 40.0 => Some(c.center), + egui::Shape::Vec(shapes) => shapes.iter().find_map(centre), + _ => None, + } + } + h.output().shapes.iter().find_map(|shape| centre(&shape.shape)).unwrap() + } + + #[test] + fn tip_geometry_fields_precede_the_widget_and_labels_align_in_all_themes() { + for kind in theme::ThemeKind::ALL { + for width in [280.0, 340.0] { + let b = BrushSettings { angle: -23.0, roundness: 0.473, ..Default::default() }; + let mut h = section_harness_width(0, width, b.clone()); + crate::PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + let centre = tip_geometry_center(&h); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap().rect(); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap().rect(); + let angle_label = h.get_by_label("Angle:").rect(); + let roundness_label = h.get_by_label("Roundness:").rect(); + assert!( + angle.right() + 2.0 < centre.x - 40.0 && roundness.right() + 2.0 < centre.x - 40.0, + "{kind:?}, {width}: fields belong left of the widget" + ); + assert!((angle.left() - roundness.left()).abs() < 0.5, "{kind:?}, {width}: one numeric column"); + assert!((angle_label.right() - roundness_label.right()).abs() < 0.5, "{kind:?}, {width}: labels align to the right"); + assert!((angle_label.center().y - angle.center().y).abs() < 2.0 && (roundness_label.center().y - roundness.center().y).abs() < 2.0); + assert_eq!(h.state(), &b, "layout does not round imported values"); + } + } + } + + #[test] + fn tip_geometry_numeric_edits_keep_units_limits_and_other_settings() { + let mut b = BrushSettings { angle: -23.0, roundness: 0.473, hardness: 0.65, spacing: 0.31, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let angle = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(-23.0)).unwrap(); + angle.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.angle = -22.0; + assert_eq!(h.state(), &b); + let roundness = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(47.0)).unwrap(); + roundness.click(); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + b.roundness = 0.48; + assert_eq!(h.state(), &b, "percentage edits retain degrees and the rest of the brush"); + for (angle, key) in [(-180.0, egui::Key::ArrowDown), (180.0, egui::Key::ArrowUp)] { + b.angle = angle; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(angle))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "angle stays within -180..180 degrees"); + } + for (value, key) in [(1.0, egui::Key::ArrowDown), (100.0, egui::Key::ArrowUp)] { + b.roundness = value / 100.0; + *h.state_mut() = b.clone(); + h.run_steps(2); + let field = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(f64::from(value))).unwrap(); + field.click(); + h.key_press(key); + h.run_steps(2); + assert_eq!(h.state(), &b, "roundness stays within 1..100 percent"); + } + } + + #[test] + fn tip_geometry_pointer_drags_keep_angle_and_roundness_independent() { + let mut b = BrushSettings { roundness: 0.5, flip_x: true, ..Default::default() }; + let mut h = section_harness(0, b.clone()); + let c = tip_geometry_center(&h); + drag(&mut h, c + vec2(35.0, 0.0), c + vec2(0.0, -35.0)); + b.angle = 90.0; + assert_eq!(h.state(), &b, "the arrow changes only the angle"); + let handle = c + roundness_handles(b.angle, b.roundness)[0]; + // Acquire the dot before moving beyond its hit radius. + h.hover_at(handle); + h.run_steps(2); + h.drag_at(handle); + h.run_steps(1); + h.hover_at(handle + vec2(-6.5, 0.0)); + h.run_steps(2); + h.hover_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + h.drop_at(c + vec2(-ELLIPSE_R * 0.8, 0.0)); + h.run_steps(2); + b.roundness = 0.8; + assert!((h.state().roundness - b.roundness).abs() < 1e-5, "a real handle drag keeps percentage units"); + b.roundness = h.state().roundness; + assert_eq!(h.state(), &b, "the dot changes only the roundness"); + } + /// #3825: jitter is an amount, not a hue selector; it uses the themed amount track. #[test] fn hue_jitter_paints_an_amount_track_in_every_theme() { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 7a5e4998207..4267b7cbe02 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Angle/Roundness columns and label alignment; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -19,6 +19,7 @@ tablets against Photoshop side by side. | Section | Status | Evidence | |---|---|---| | Brush Tip Shape: size, angle, roundness, hardness, spacing, flip X/Y | done | `brush.rs` | +| Brush Tip Shape places aligned Angle/Roundness controls left of the interactive widget | done | #3825 (Angle/Roundness layout only); UI regressions cover 14 themes at two panel widths, imported-value retention, numeric limits, actual arrow/handle gestures and existing command routing. | | Shape Dynamics (size/angle/roundness jitter, pen pressure/tilt/rotation/wheel controls, minimums, flip jitter, brush projection) | done | #174 | | Scattering (both axes, count, count jitter) | done | | | Texture (pattern, scale, brightness, contrast, mode, depth, each tip) | done | | @@ -121,3 +122,4 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Angle/Roundness controls use right-aligned labels in the left column, with the interactive widget on the right (#3825, partial). Units, limits and gestures retained; no brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab85..d1b357a7d62 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Angle/Roundness column alignment, #3825 partial; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,6 +156,10 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Brush Tip Shape places Angle/Roundness + controls in a left column with right-aligned labels and the interactive widget on + the right. Numeric units, limits, imported values and arrow/handle interactions + are retained; the assessment's other layout and control gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -264,6 +268,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: Angle/Roundness column and label alignment (#3825, partial); existing values and interactions retained; no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | From ff0eb52d7a338dfc7585e29b5de6882e58edf953 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 007/384] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f317..b7a5a4cb850 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 01e4ef5ef59fae458735baa717b234d196dd4cd6 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 008/384] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f317..b7a5a4cb850 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 01a016e226bcb9ff862801cb8f4a14795c2b1110 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:12:44 +0300 Subject: [PATCH 009/384] test(autosave): wait for the retried snapshot metadata Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f317..b7a5a4cb850 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -932,7 +932,9 @@ mod tests { while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - if !recovered.is_empty() { + // A committed bundle is recoverable before its sidecar is published. Wait for + // the sidecar's revision, not the early fallback entry (revision 0). + if recovered.len() == 1 && recovered[0].info.revision == revision { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From b0e30c3ed02b276f51e0c31454f8cf004577e140 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 010/384] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb850..e6c882d1989 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 2813f5e348a02e0fc9e054a33b105bf71fb4f5b9 Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 011/384] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb850..e6c882d1989 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 86763ab255aae52a0e287ef56ba8a44a9808866b Mon Sep 17 00:00:00 2001 From: augrclk <217665348+augrclk@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:20:54 +0300 Subject: [PATCH 012/384] test(autosave): align retry coverage with upstream Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes. Co-authored-by: GPT-6 --- apps/photocraft/src/services.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b7a5a4cb850..e6c882d1989 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -907,6 +907,7 @@ mod tests { let mut app = launch(&recovery); new_doc(&mut app, "#ff0000"); let revision = app.session.active().unwrap().revision; + assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0"); autosave(&mut app, &ctx); let mut failed = false; @@ -927,14 +928,16 @@ mod tests { std::fs::remove_file(&recovery).unwrap(); std::fs::create_dir(&recovery).unwrap(); autosave(&mut app, &ctx); + // The worker commits the bundle's manifest before it publishes the sidecar, and + // `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so + // the first non-empty listing can come from the middle of the write. Wait for the write + // to finish: the entry then carries the sidecar's revision. let mut recovered = Vec::new(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { prefs_ui::tick(&mut app, &ctx); recovered = list_recovery(&recovery); - // A committed bundle is recoverable before its sidecar is published. Wait for - // the sidecar's revision, not the early fallback entry (revision 0). - if recovered.len() == 1 && recovered[0].info.revision == revision { + if recovered.iter().any(|e| e.info.revision == revision) { break; } std::thread::sleep(std::time::Duration::from_millis(10)); From 16929b0b5a7a788a51a72b78107baa22d599fa7d Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:20:29 -0400 Subject: [PATCH 013/384] Fix live Mixer Brush rendering and extra brush frame delay --- crates/engine/src/brush_cmds.rs | 46 ++++- crates/engine/src/brush_cmds/mixer_live.rs | 207 +++++++++++++++++++++ crates/engine/src/lib.rs | 6 + crates/engine/src/retouch_cmds.rs | 2 +- crates/engine/tests/live_mixer.rs | 169 +++++++++++++++++ crates/paint/src/mixer.rs | 108 +++++++---- crates/ui-egui/src/canvas.rs | 89 ++++++++- crates/ui-egui/tests/live_stroke_canvas.rs | 51 +++++ docs/brush-parity.md | 9 +- docs/gaps.md | 11 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 3 +- scorecard/tools.toml | 2 +- 13 files changed, 644 insertions(+), 61 deletions(-) create mode 100644 crates/engine/src/brush_cmds/mixer_live.rs create mode 100644 crates/engine/tests/live_mixer.rs diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 3de6bc84a11..febbfdfa20a 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -8,7 +8,10 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; -use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; +use photocraft_paint::mixer::{MixerSettings, MixerState, apply_mixer_stroke}; + +mod mixer_live; +pub use mixer_live::LiveMixer; use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; use photocraft_paint::tile::PREVIEW_SIDE; use photocraft_paint::{ @@ -458,6 +461,7 @@ pub struct LiveCommit { pub(crate) points: Vec, pub(crate) surface: Surface, pub(crate) bounds: Rect, + pub(crate) mixer: Option, } impl LiveCommit { @@ -541,7 +545,16 @@ impl LiveStroke { pub fn into_commit(mut self) -> Result { let bounds = self.bounds(); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; - Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds }) + Ok(LiveCommit { + cmd: self.cmd, + base: self.base, + params: self.params, + seed: Some(self.seed), + points: self.points, + surface: surf.clone(), + bounds, + mixer: None, + }) } /// Everything the stroke has touched so far. @@ -632,7 +645,7 @@ fn pct(p: &Value, k: &str, d: f32) -> f32 { num(p, k).unwrap_or(d).clamp(0.0, 100.0) / 100.0 } -fn mixer_brush(s: &mut Session, p: &Value) -> Result { +fn mixer_setup(s: &Session, p: &Value) -> Result<(Stroke, MixerSettings, MixerState, Option)> { let cmd = "paint.mixerBrush"; let pts = parse_points(p, cmd)?; let brush = resolve_brush(s, p, cmd)?; @@ -645,15 +658,30 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { flow: pct(p, "flow", bm.flow * 100.0), sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), }; - let sample_all = m.sample_all_layers; - let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); + let load_after = flag(p, "loadAfterStroke", true); let mut state = s.tools.mixer.clone(); if load_after || state.reservoir.is_none() { state.load(color(p.get("color"), s.tools.foreground)); } let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; let stroke = Stroke { brush, points: pts }; + Ok((stroke, m, state, id)) +} + +fn mixer_brush(s: &mut Session, p: &Value) -> Result { + let (stroke, m, mut state, id) = mixer_setup(s, p)?; + let sample_all = m.sample_all_layers; + let live = s.live_commit.take().filter(|l| l.matches(s, "paint.mixerBrush", p, &stroke.points) && l.mixer.is_some()); + let from_live = live.is_some(); let dmg = s.edit("Mixer Brush", |doc, _| { + if let Some(live) = live { + let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + *surf = live.surface; + if let Some(mixer) = live.mixer { + state = mixer; + } + return Ok(live.bounds); + } let sel = doc.selection.clone(); let sample = if sample_all { let ds = photocraft_paint::dabs(&stroke); @@ -672,11 +700,15 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) })?; - if clean { + if flag(p, "cleanAfterStroke", true) { state.clean(); } s.tools.mixer = state; - Ok(damage_json(s, dmg)) + let mut result = damage_json(s, dmg); + if from_live { + result["fromLive"] = json!(true); + } + Ok(result) } /// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour diff --git a/crates/engine/src/brush_cmds/mixer_live.rs b/crates/engine/src/brush_cmds/mixer_live.rs new file mode 100644 index 00000000000..89560756bb4 --- /dev/null +++ b/crates/engine/src/brush_cmds/mixer_live.rs @@ -0,0 +1,207 @@ +//! Incremental Mixer Brush preview, with the same dabs and mixing as the command. + +use std::sync::Arc; + +use photocraft_doc::{Document, LayerId}; +use photocraft_paint::mixer::MixerDab; +use photocraft_paint::retouch::{Region, mix_back}; + +use super::*; + +/// Mixer Brush pixels and carried paint while a gesture is in progress. +pub struct LiveMixer { + /// Document containing the current preview, without changing session history. + pub doc: Arc, + /// Jitter seed to record with the committed command. + pub seed: u64, + pre_doc: Arc, + pre: Surface, + work: Surface, + renderer: StrokeRenderer, + mixer: MixerDab, + settings: MixerSettings, + brush: BrushSettings, + initial: MixerState, + layer: Option, + params: Value, + points: Vec, + base: (photocraft_doc::DocId, u64), + selection: Option, + lock: bool, + done: usize, + bounds: Rect, + tail: Rect, +} + +impl LiveMixer { + /// Start a preview using the same parameters as `paint.mixerBrush`. + pub fn begin(s: &Session, p: &Value) -> Result { + has_paintable(s).map_err(EngineError::Other)?; + let (stroke, settings, initial, layer) = mixer_setup(s, p)?; + let active = s.active().ok_or(EngineError::NoDocument)?; + let mut doc = (*active.doc).clone(); + let selection = doc.selection.clone(); + let (surface, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?; + let pre = surface.clone(); + // Keep working pixels at float precision between dabs, as apply_dab_stroke's Region + // does. Quantizing every dab into an 8/16-bit Surface would change the final blend. + let mut format = pre.format(); + format.sample = photocraft_color::SampleType::F32; + let work = Surface::with_default(format, &pre.default_pixel()); + let mixer = MixerDab::new(pre.format(), &settings, &initial, lock); + let mut dab_brush = stroke.brush.clone(); + dab_brush.opacity = 1.0; + let mut live = Self { + doc: Arc::new(doc), + pre_doc: active.doc.clone(), + pre, + work, + renderer: StrokeRenderer::new(&dab_brush, None, 1.0).record_dabs(), + mixer, + settings, + seed: stroke.brush.seed, + brush: stroke.brush, + initial, + layer, + params: p.clone(), + points: Vec::new(), + base: (active.doc.id, active.revision), + selection, + lock, + done: 0, + bounds: Rect::EMPTY, + tail: Rect::EMPTY, + }; + live.push(&stroke.points)?; + Ok(live) + } + + /// Rectangle touched so far, including the finishing dab shown at the pointer. + pub fn bounds(&self) -> Rect { + self.bounds.union(&self.tail) + } + + /// Whether airbrush build-up or smoothing still needs time while held. + pub fn wants_time(&self) -> bool { + self.renderer.wants_time() + } + + fn sample(&self, rect: Rect) -> Option { + if !self.settings.sample_all_layers || rect.is_empty() { + return None; + } + let buf = photocraft_compose::render(&self.pre_doc, rect); + let mut sample = Surface::new(photocraft_color::PixelFormat::RGBA32F); + sample.write_region(rect, &buf.px.iter().flatten().copied().collect::>()); + Some(sample) + } + + fn working_region(&self, rect: Rect) -> Region { + let mut region = Region::read(&self.pre, rect); + for key in rect.tiles().filter(|key| self.work.tile(*key).is_some()) { + let touched = key.rect().intersect(&rect); + let work = Region::read(&self.work, touched); + for y in touched.y0..touched.y1 { + for x in touched.x0..touched.x1 { + region.px_mut(x, y).copy_from_slice(work.px(x, y)); + } + } + } + region + } + + fn draw_pending(&mut self, show_tail: bool) -> Result { + let ctx = self.renderer.ctx.clone(); + let dabs = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); + let dirty = dabs.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))); + Self::check_region(dirty)?; + let sample = self.sample(dirty); + for (i, dab) in dabs.iter().enumerate() { + let r = ctx.dab_rect(dab, false); + if r.is_empty() { + continue; + } + // Initialize just this dab's new tiles, retaining float precision on overlaps. + for key in r.tiles() { + if self.work.tile(key).is_none() && self.pre.tile(key).is_some() { + let tile = key.rect(); + self.work.write_region(tile, &self.pre.read_region(tile)); + } + } + let mut region = Region::read(&self.work, r); + self.mixer.apply(&mut region, &ctx.footprint(dab, self.done + i), sample.as_ref()); + region.write(&mut self.work); + } + self.done += dabs.len(); + self.bounds = self.bounds.union(&dirty); + let old_tail = std::mem::replace(&mut self.tail, Rect::EMPTY); + let changed = dirty.union(&old_tail); + Self::check_region(changed)?; + let tail_dabs = if show_tail { self.renderer.tail_dabs() } else { Vec::new() }; + let tail = tail_dabs.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))); + Self::check_region(tail)?; + let mut tail_work = None; + if !tail.is_empty() { + // Finishing the stroke is speculative while drawing: neither reservoir depletion + // nor pickup from this tail may leak into the next input batch. + let mut region = self.working_region(tail); + let mut mixer = self.mixer.clone(); + let sample = self.sample(tail); + for (i, dab) in tail_dabs.iter().enumerate() { + mixer.apply(&mut region, &ctx.footprint(dab, self.done + i), sample.as_ref()); + } + tail_work = Some(region); + self.tail = tail; + } + let working = (!changed.is_empty()).then(|| self.working_region(changed)); + let (surface, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?; + if !changed.is_empty() { + let work = working.ok_or_else(|| bad("paint.mixerBrush", "missing working pixels"))?; + mix_back(surface, &Region::read(&self.pre, changed), &work, changed, 1.0, self.selection.as_ref(), self.lock); + } + if let Some(work) = tail_work { + mix_back(surface, &Region::read(&self.pre, tail), &work, tail, 1.0, self.selection.as_ref(), self.lock); + } + Ok(changed.union(&tail)) + } + + fn check_region(rect: Rect) -> Result<()> { + // Bound input-derived temporary windows before Region or compose allocates. A long + // diagonal pointer jump must not request a document-sized dense buffer without a cap. + let pixels = u64::from(rect.width()).checked_mul(u64::from(rect.height())); + if pixels.is_none_or(|pixels| pixels > 16 * 1024 * 1024) { + return Err(bad("paint.mixerBrush", "live Mixer Brush window exceeds 16 million pixels; use a smaller brush or shorter input batches")); + } + Ok(()) + } + + /// Render a batch of pointer samples, rejecting invalid input before changing the preview. + pub fn push(&mut self, points: &[StrokePoint]) -> Result { + check_coords(points, "paint.mixerBrush")?; + self.points.extend_from_slice(points); + self.renderer.push(points); + self.draw_pending(true) + } + + pub(crate) fn into_commit(mut self, s: &Session) -> Result { + let mut p = self.params.clone(); + p["seed"] = json!(self.seed); + let (stroke, settings, initial, layer) = mixer_setup(s, &p)?; + if stroke.brush != self.brush || settings != self.settings || initial != self.initial || layer != self.layer { + return Err(bad("paint.mixerBrush", "Mixer Brush settings changed during the stroke")); + } + self.renderer.finish(); + self.draw_pending(false)?; + let (surface, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?; + Ok(LiveCommit { + cmd: "paint.mixerBrush".into(), + base: self.base, + params: self.params, + seed: Some(self.seed), + points: self.points, + surface: surface.clone(), + bounds: self.bounds, + mixer: Some(self.mixer.state()), + }) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba7556901bd..f50adcedcf8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -672,6 +672,12 @@ impl Session { Ok(()) } + /// Offer the Mixer Brush's rendered pixels and carried paint to its normal command. + pub fn offer_live_mixer(&mut self, live: brush_cmds::LiveMixer) -> Result<()> { + self.live_commit = Some(live.into_commit(self)?); + Ok(()) + } + /// Withdraw an offer the command didn't take. pub fn clear_live_stroke(&mut self) { self.live_commit = None; diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 8ef8722c16b..b186f30e54f 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -533,7 +533,7 @@ impl LiveRetouch { let bounds = self.bounds(); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; let surface = surf.clone(); - Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds }) + Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds, mixer: None }) } /// Render more points; returns the rectangle that changed. Each changed area is redrawn from diff --git a/crates/engine/tests/live_mixer.rs b/crates/engine/tests/live_mixer.rs new file mode 100644 index 00000000000..855959669c2 --- /dev/null +++ b/crates/engine/tests/live_mixer.rs @@ -0,0 +1,169 @@ +//! #5196: Mixer Brush previews are real pixels, with one command and history step on release. + +use photocraft_engine::{Session, brush_cmds::LiveMixer}; +use photocraft_geom::Rect; +use photocraft_paint::StrokePoint; +use serde_json::{Value, json}; + +fn session(depth: u8, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 128, "height": 64, "depth": depth, "mode": mode})).unwrap(); + s.execute("edit.fill", json!({"color": "#2040df"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"size": 18, "hardness": 0.7, "smoothing": {"amount": 0.0}, "pressureSize": false}})).unwrap(); + s +} + +fn pixels(doc: &photocraft_doc::Document) -> Vec { + doc.layers[0].surface().unwrap().read_region(Rect::new(0, 0, 128, 64)) +} + +fn full_params(all: bool) -> Value { + json!({"points": [[12,32,1],[60,32,0.6],[112,32,1]], "color": "#ef2000", "wet": 80, "mix": 75, + "load": 30, "flow": 60, "seed": 57, "sampleAllLayers": all, "cleanAfterStroke": false}) +} + +#[test] +fn live_mixer_matches_command_pixels_and_reservoir_at_every_depth_and_mode() { + for depth in [8, 16, 32] { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for all in [false, true] { + let mut s = session(depth, mode); + let p = full_params(all); + let before = pixels(&s.active().unwrap().doc); + let initial_state = s.tools.mixer.clone(); + let mut start = p.clone(); + start["points"] = json!([[12, 32, 1]]); + let mut live = LiveMixer::begin(&s, &start).unwrap(); + assert_ne!(pixels(&live.doc), before, "a press paints before release"); + live.push(&[StrokePoint::new(60.0, 32.0, 0.6)]).unwrap(); + live.push(&[StrokePoint::new(112.0, 32.0, 1.0)]).unwrap(); + assert_eq!(pixels(&s.active().unwrap().doc), before, "preview doesn't edit the session"); + assert_eq!(s.tools.mixer, initial_state, "preview doesn't consume session paint"); + let mut oracle = session(depth, mode); + oracle.execute("paint.mixerBrush", p.clone()).unwrap(); + s.offer_live_mixer(live).unwrap(); + let result = s.execute("paint.mixerBrush", p).unwrap(); + assert_eq!(result["fromLive"], true); + assert_eq!(pixels(&s.active().unwrap().doc), pixels(&oracle.active().unwrap().doc), "{depth} {mode} all={all}"); + assert_eq!(s.tools.mixer, oracle.tools.mixer); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Mixer Brush")); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&s.active().unwrap().doc), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(pixels(&s.active().unwrap().doc), pixels(&oracle.active().unwrap().doc)); + } + } + } +} + +#[test] +fn invalid_live_mixer_points_leave_preview_and_session_unchanged() { + let s = session(8, "rgb"); + let mut p = full_params(false); + p["points"] = json!([[12, 32, 1]]); + let mut live = LiveMixer::begin(&s, &p).unwrap(); + let before = pixels(&live.doc); + assert!(live.push(&[StrokePoint::new(f64::NAN, 32.0, 1.0)]).is_err()); + assert_eq!(pixels(&live.doc), before); + assert!(LiveMixer::begin(&s, &json!({"points": []})).is_err()); + assert!(LiveMixer::begin(&Session::new(), &p).is_err()); +} + +#[test] +fn changed_mixer_settings_or_document_refuse_the_offer() { + let mut s = session(8, "rgb"); + let p = full_params(false); + let live = LiveMixer::begin(&s, &p).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"size": 30}})).unwrap(); + assert!(s.offer_live_mixer(live).is_err()); + let live = LiveMixer::begin(&s, &p).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.offer_live_mixer(live).unwrap(); + let result = s.execute("paint.mixerBrush", p).unwrap(); + assert!(result.get("fromLive").is_none(), "changed revision falls back to the command"); +} + +#[test] +fn changed_commit_points_cannot_take_stale_preview_pixels() { + let mut s = session(8, "rgb"); + let p = full_params(false); + let live = LiveMixer::begin(&s, &p).unwrap(); + s.offer_live_mixer(live).unwrap(); + let mut changed = p; + changed["points"] = json!([[12, 12, 1], [112, 12, 1]]); + let result = s.execute("paint.mixerBrush", changed).unwrap(); + assert!(result.get("fromLive").is_none()); +} + +#[test] +fn live_mixer_tail_matches_smoothing_dynamics_and_selection() { + for amount in [0.0, 0.6] { + let setup = || { + let mut s = session(16, "rgb"); + s.execute( + "tools.setBrush", + json!({"brush":{"opacity":0.3,"smoothing":{"amount":amount}, + "shapeDynamics":{"enabled":true,"size":{"jitter":0.2}}, + "transfer":{"enabled":true,"wetness":{"jitter":0.3},"mix":{"jitter":0.2}}}}), + ) + .unwrap(); + s.execute("select.rect", json!({"x":40,"y":0,"width":40,"height":64})).unwrap(); + s + }; + let mut s = setup(); + let p = full_params(false); + let mut start = p.clone(); + start["points"] = json!([[12, 32, 1]]); + let mut live = LiveMixer::begin(&s, &start).unwrap(); + live.push(&[StrokePoint::new(60.0, 32.0, 0.6), StrokePoint::new(112.0, 32.0, 1.0)]).unwrap(); + let shown = pixels(&live.doc); + let mut oracle = setup(); + oracle.execute("paint.mixerBrush", p.clone()).unwrap(); + assert_eq!(shown, pixels(&oracle.active().unwrap().doc), "finishing tail is shown without consuming paint"); + s.offer_live_mixer(live).unwrap(); + assert_eq!(s.execute("paint.mixerBrush", p).unwrap()["fromLive"], true); + assert_eq!(pixels(&s.active().unwrap().doc), shown); + assert_eq!(s.tools.mixer, oracle.tools.mixer); + } +} + +#[test] +#[ignore = "release benchmark on a 24 MP synthetic canvas"] +fn live_mixer_24mp_benchmark() { + use std::time::Instant; + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"depth":16})).unwrap(); + s.execute("tools.setBrush", json!({"brush":{"size":60,"hardness":0.7,"smoothing":{"amount":0.0}}})).unwrap(); + let params = json!({"points":[[100,2000,1]],"wet":70,"mix":80,"seed":57}); + let points: Vec<_> = (0..100).map(|i| StrokePoint::new(100.0 + i as f64 * 10.0, 2000.0, 1.0)).collect(); + let mut baseline = Vec::new(); + let mut incremental = Vec::new(); + let mut releases = Vec::new(); + for _ in 0..5 { + let mut oracle = Session::new(); + oracle.execute("file.new", json!({"width":6000,"height":4000,"depth":16})).unwrap(); + oracle.tools.brush = s.tools.brush.clone(); + let mut p = params.clone(); + p["points"] = json!(points); + let t = Instant::now(); + oracle.execute("paint.mixerBrush", p.clone()).unwrap(); + baseline.push(t.elapsed().as_secs_f64() * 1000.0); + let mut live = LiveMixer::begin(&s, ¶ms).unwrap(); + for point in &points[1..] { + let t = Instant::now(); + live.push(&[*point]).unwrap(); + incremental.push(t.elapsed().as_secs_f64() * 1000.0); + } + let t = Instant::now(); + s.offer_live_mixer(live).unwrap(); + assert_eq!(s.execute("paint.mixerBrush", p).unwrap()["fromLive"], true); + releases.push(t.elapsed().as_secs_f64() * 1000.0); + s.execute("edit.undo", json!({})).unwrap(); + } + for (label, values) in + [("full stroke on release (before)", &mut baseline), ("live pointer step (after)", &mut incremental), ("reuse on release (after)", &mut releases)] + { + values.sort_by(f64::total_cmp); + eprintln!("24 MP {label}: median {:.3} ms, p95 {:.3} ms", values[values.len() / 2], values[(values.len() * 95 / 100).min(values.len() - 1)]); + } +} diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index afb8092cd2f..68d003b4df5 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -6,11 +6,12 @@ //! weight. The reservoir amount drains per dab unless `load` is 100 %, so low Load strokes dry out. //! A dry brush (`wet = 0`) paints only reservoir colour; an empty, wet brush smears canvas colour. +use photocraft_color::PixelFormat; use photocraft_raster::{Surface, from_rgba, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{alpha_index, apply_dab_stroke, over_native}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke, over_native}; use photocraft_geom::Rect; /// Fraction of the way the pickup colour moves towards the canvas colour per dab at 100 % Wet. @@ -69,30 +70,61 @@ pub fn apply_mixer_stroke( selection: Option<&Surface>, lock_transparency: bool, ) -> Rect { - let fmt = target.format(); - let n = fmt.channels(); - let a = alpha_index(&fmt); - let nc = fmt.mode.color_channels(); - let native = |c: [f32; 4]| { - let mut v = from_rgba(&fmt, c); - if let Some(ai) = a { - v[ai] = 1.0; - } - v - }; - let res = state.reservoir.map(native); - let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 }; - let mut pickup: Option> = state.pickup.map(native); - let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); + let mut mixer = MixerDab::new(target.format(), m, state, lock_transparency); let mut st = stroke.clone(); st.brush.opacity = 1.0; - let mut acc = vec![0.0f32; n]; - let mut paint = vec![0.0f32; n]; - let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| mixer.apply(work, fp, sample)); + *state = mixer.state(); + dmg +} + +/// Incremental Mixer Brush paint carried between dabs. Shared by live and committed strokes. +#[derive(Clone)] +pub struct MixerDab { + fmt: PixelFormat, + reservoir: Option<[f32; 4]>, + res: Option>, + pickup: Option>, + amount: f32, + wet: f32, + mix: f32, + flow: f32, + load: f32, + lock_transparency: bool, + acc: Vec, + paint: Vec, +} + +impl MixerDab { + /// Start from the tool's carried paint, in the target's native colour model. + pub fn new(fmt: PixelFormat, settings: &MixerSettings, state: &MixerState, lock_transparency: bool) -> Self { + let n = fmt.channels(); + let a = alpha_index(&fmt); + let native = |c: [f32; 4]| { + let mut v = from_rgba(&fmt, c); + if let Some(ai) = a { + v[ai] = 1.0; + } + v + }; + let res = state.reservoir.map(native); + let amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 }; + let pickup: Option> = state.pickup.map(native); + let (wet, mix, flow, load) = (settings.wet.clamp(0.0, 1.0), settings.mix.clamp(0.0, 1.0), settings.flow.clamp(0.0, 1.0), settings.load.clamp(0.0, 1.0)); + Self { fmt, reservoir: state.reservoir, res, pickup, amount, wet, mix, flow, load, lock_transparency, acc: vec![0.0; n], paint: vec![0.0; n] } + } + + /// Paint one dab, sampling the working pixels or an all-layer composite of its footprint. + pub fn apply(&mut self, work: &mut Region, fp: &Footprint, sample: Option<&Surface>) { + let fmt = self.fmt; + let n = fmt.channels(); + let a = alpha_index(&fmt); + let nc = fmt.mode.color_channels(); + let lock_transparency = self.lock_transparency; // Transfer › Wetness / Mix Jitter scale the settings per dab. - let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); + let (wet, mix) = (self.wet * fp.dab.wet.clamp(0.0, 1.0), self.mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. - acc.iter_mut().for_each(|v| *v = 0.0); + self.acc.iter_mut().for_each(|v| *v = 0.0); let (mut wsum, mut asum) = (0.0f32, 0.0f32); let r = fp.rect.intersect(&work.rect); for y in r.y0..r.y1 { @@ -109,8 +141,8 @@ pub fn apply_mixer_stroke( None => work.px(x, y).to_vec(), }; let al = a.map_or(1.0, |ai| px[ai]); - for c in 0..nc { - acc[c] += px[c] * w * al; + for (acc, value) in self.acc.iter_mut().zip(&px).take(nc) { + *acc += value * w * al; } wsum += w; asum += w * al; @@ -118,36 +150,38 @@ pub fn apply_mixer_stroke( } if asum > 1e-6 && wet > 0.0 { let ca = asum / wsum.max(1e-6); - let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); - pickup = Some(match pickup.take() { + let canvas: Vec = (0..n).map(|c| if c < nc { self.acc[c] / asum } else { 1.0 }).collect(); + self.pickup = Some(match self.pickup.take() { None => canvas, Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), }); } - let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 }; - let wp = if pickup.is_some() { mix * wet } else { 0.0 }; + let wr = if self.res.is_some() { self.amount * (1.0 - mix * wet) } else { 0.0 }; + let wp = if self.pickup.is_some() { mix * wet } else { 0.0 }; let total = wr + wp; if total > 1e-6 { for c in 0..n { - let rv = res.as_ref().map_or(0.0, |v| v[c]); - let pv = pickup.as_ref().map_or(0.0, |v| v[c]); - paint[c] = (rv * wr + pv * wp) / total; + let rv = self.res.as_ref().map_or(0.0, |v| v[c]); + let pv = self.pickup.as_ref().map_or(0.0, |v| v[c]); + self.paint[c] = (rv * wr + pv * wp) / total; } - let dep = total.min(1.0) * flow; + let dep = total.min(1.0) * self.flow; for y in r.y0..r.y1 { for x in r.x0..r.x1 { let k = fp.at(x, y) * dep; if k > 0.0 { - over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency); + over_native(&fmt, work.px_mut(x, y), &self.paint, k, lock_transparency); } } } } - amount *= 1.0 - 0.08 * (1.0 - load); - }); - state.amount = amount; - state.pickup = pickup.map(|p| to_rgba(&fmt, &p)); - dmg + self.amount *= 1.0 - 0.08 * (1.0 - self.load); + } + + /// Paint remaining after the dabs rendered so far. + pub fn state(&self) -> MixerState { + MixerState { reservoir: self.reservoir, amount: self.amount, pickup: self.pickup.as_ref().map(|p| to_rgba(&self.fmt, p)) } + } } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 184e9ccce31..5fad8e5f53b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -437,6 +437,7 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor /// Healing Brush and History Brush composite their source. enum EngineStroke { Brush(Box), + Mixer(Box), Retouch(photocraft_engine::retouch_cmds::LiveRetouch), Dab(photocraft_engine::retouch_cmds::LiveDab), } @@ -447,6 +448,7 @@ impl EngineStroke { fn wants_time(&self) -> bool { match self { EngineStroke::Brush(l) => l.wants_time(), + EngineStroke::Mixer(l) => l.wants_time(), EngineStroke::Retouch(_) | EngineStroke::Dab(_) => false, } } @@ -454,6 +456,7 @@ impl EngineStroke { fn doc(&self) -> &std::sync::Arc { match self { Self::Brush(s) => &s.doc, + Self::Mixer(s) => &s.doc, Self::Retouch(s) => &s.doc, Self::Dab(s) => &s.doc, } @@ -461,6 +464,7 @@ impl EngineStroke { fn bounds(&self) -> DRect { match self { Self::Brush(s) => s.bounds(), + Self::Mixer(s) => s.bounds(), Self::Retouch(s) => s.bounds(), Self::Dab(s) => s.bounds(), } @@ -468,6 +472,7 @@ impl EngineStroke { fn push(&mut self, pts: &[photocraft_engine::paint::StrokePoint]) -> photocraft_engine::Result { match self { Self::Brush(s) => s.push(pts), + Self::Mixer(s) => s.push(pts), Self::Retouch(s) => s.push(pts), Self::Dab(s) => s.push(pts), } @@ -476,6 +481,7 @@ impl EngineStroke { fn seed(&self) -> Option { match self { Self::Brush(s) => Some(s.seed), + Self::Mixer(s) => Some(s.seed), Self::Retouch(_) => None, Self::Dab(_) => None, } @@ -551,6 +557,9 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> { /// stroke smoothing is the session brush's (the options bar's Smoothing %). fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) -> serde_json::Value { let tool = d.tool; + if tool == Tool::MixerBrush { + return json!({ "points": points, "target": paint_target(app) }); + } let mut p = json!({ "points": points, "erase": d.erase, "zoom": app.point_zoom(), "target": paint_target(app) }); if d.clear { p["mode"] = json!("clear"); @@ -570,6 +579,7 @@ pub(crate) fn strokes_live(tool: Tool) -> bool { matches!( tool, Tool::Brush + | Tool::MixerBrush | Tool::Pencil | Tool::Eraser | Tool::ColorReplacement @@ -596,6 +606,9 @@ fn live_retouch_command(tool: Tool) -> Option<&'static str> { /// The command a live-stroking tool commits: the Pencil's (and the Pencil- or Block-mode /// Eraser's) `paint.pencil`, else `paint.stroke`. pub(crate) fn stroke_command(app: &PhotocraftApp, tool: Tool) -> &'static str { + if tool == Tool::MixerBrush { + return "paint.mixerBrush"; + } if tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool) || crate::eraser_ui::block_mode(app, tool) { "paint.pencil" } else { "paint.stroke" } } @@ -697,7 +710,10 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let st = app.session.active()?; let d = app.drag.as_ref()?; - let stroke = if d.tool == Tool::ColorReplacement { + let stroke = if d.tool == Tool::MixerBrush { + let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); + EngineStroke::Mixer(Box::new(photocraft_engine::brush_cmds::LiveMixer::begin(&app.session, &p).ok()?)) + } else if d.tool == Tool::ColorReplacement { // The params `retouch_ui::finish_stroke` commits. let mut p = crate::retouch_ui::color_replacement_params(app); p["points"] = json!(d.points); @@ -3115,9 +3131,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: None => paint_dots(ui, rect, app.session.prefs().interface.show_canvas_dots), } let border = app.session.prefs().interface.canvas_border; - // Default-mode Crop changes its camera during input. Defer its image until the new camera - // is known; other tools keep their existing paint order and texture/outline snapshots. + // Default-mode Crop changes its camera during input. Live painting must also process the + // current pointer samples before preparing the image, or every dab appears a frame late. let crop_camera = primary && crate::crop_mode::moves_image(app); + let paint_after_input = crop_camera || (primary && strokes_live(app.ui.tool)); let paint_canvas = |app: &mut PhotocraftApp, ui: &egui::Ui, view: &View, xf: ViewXform| { let point_zoom = view.zoom / ppp; let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow; @@ -3362,7 +3379,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: (img_rect, beyond, layer_edges) }; - let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &view, xf)) }; + let painted = if paint_after_input { None } else { Some(paint_canvas(app, ui, &view, xf)) }; let img_rect = xf.doc_rect(doc.bounds()); // Under an open dialog the canvas widget is inert, but the image still pans and zooms. @@ -5900,9 +5917,12 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } let LiveStroke { stroke, doc, key, damage, .. } = l; let before = app.session.documents().iter().find(|st| st.doc.id == doc).map(|st| st.doc.clone()); - if let EngineStroke::Brush(stroke) = stroke - && app.session.offer_live_stroke(*stroke).is_err() - { + let offer = match stroke { + EngineStroke::Brush(stroke) => app.session.offer_live_stroke(*stroke), + EngineStroke::Mixer(stroke) => app.session.offer_live_mixer(*stroke), + _ => Ok(()), + }; + if offer.is_err() { app.session.clear_live_stroke(); } let started = crate::gpu_canvas::now_ms(); @@ -5952,6 +5972,10 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } // A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and // History Brush's with the pixels it already drew). + if d.tool == Tool::MixerBrush { + commit_brush_stroke(app, &d); + return; + } offer_live_retouch(app, &d); if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { app.live_stroke = None; @@ -6253,6 +6277,57 @@ mod tests { assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); } + #[test] + fn live_brush_texture_includes_this_frames_press_and_moves() { + use super::*; + for tool in [Tool::Brush, Tool::MixerBrush] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 128, "height": 64, "background": "white"})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "smoothing": {"amount": 0.0}}})).unwrap(); + app.ui.tool = tool; + app.sync_views(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 640.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + let p = h.state().last_canvas_rect.center(); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + let shown = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let app = h.state_mut(); + let live = app.live_stroke.as_ref().expect("painting starts on press"); + let key = live.display_key(); + let doc = live.stroke.doc().clone(); + let (_, display) = canvas_display(app, &doc, None); + assert_eq!(app.canvases.values().next().unwrap().tex_preview_key, key ^ display, "{tool:?}: texture includes this frame's input"); + }; + shown(&mut h); + h.input_mut().events.push(egui::Event::PointerMoved(p + vec2(25.0, 0.0))); + h.run_steps(1); + shown(&mut h); + let end = p + vec2(25.0, 0.0); + h.input_mut().events.push(egui::Event::PointerButton { + pos: end, + button: PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(1); + assert!(h.state().live_stroke.is_none()); + assert!(h.state().committed_stroke.is_some(), "release reuses the shown pixels"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + } + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. diff --git a/crates/ui-egui/tests/live_stroke_canvas.rs b/crates/ui-egui/tests/live_stroke_canvas.rs index 58d6632ea3f..974b100e291 100644 --- a/crates/ui-egui/tests/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/live_stroke_canvas.rs @@ -15,6 +15,57 @@ use serde_json::{Value, json}; type Harness = egui_kittest::Harness<'static, PhotocraftApp>; type Pixels = Vec<[u8; 4]>; +#[test] +fn raw_pointer_brush_and_mixer_pixels_appear_in_the_input_frame() { + use photocraft_ui_egui::state::Tool; + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width":300,"height":200,"background":"white"})).unwrap(); + app.run("edit.fill", json!({"color":"#2040df"})).unwrap(); + app.run("tools.setColors", json!({"foreground":"#ef2000"})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":24,"hardness":1.0,"pressureSize":false,"smoothing":{"amount":0.0}}})).unwrap(); + app.ui.tool = tool; + app.ui.view.flip_horizontal = flip; + app.sync_views(); + h.run_steps(4); + let at = h.state().last_canvas_rect.center(); + let before = h.render().unwrap().get_pixel(at.x as u32, at.y as u32).0; + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + let image = h.render().unwrap(); + assert_ne!(image.get_pixel(at.x as u32, at.y as u32).0, before, "{tool:?} flip={flip}: press visible in its own frame"); + let next = at + egui::vec2(40.0, 0.0); + h.input_mut().events.push(egui::Event::PointerMoved(next)); + h.step(); + let image = h.render().unwrap(); + assert_ne!(image.get_pixel(next.x as u32, next.y as u32).0, before, "{tool:?} flip={flip}: move visible in its own frame"); + if tool == Tool::MixerBrush + && !flip + && let Ok(path) = std::env::var("PHOTOCRAFT_TEST_SNAPSHOT") + { + image.save(path).unwrap(); + } + h.input_mut().events.push(egui::Event::PointerButton { + pos: next, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some(if tool == Tool::MixerBrush { "Mixer Brush" } else { "Brush Tool" })); + } + } +} + fn harness() -> Option { let built = std::panic::catch_unwind(|| { egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 05697f9e6a0..f48f3853b9f 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (live Mixer Brush and same-frame live painting, #5196; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -58,10 +58,10 @@ tablets against Photoshop side by side. | Soft-edge falloff matches Photoshop's curve | partial | #2149, #1619 | | Brush preview and cursor crisp, not pixelated | partial | #1433 | | Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364; shaped tips trace their outline (#3467), turned and flipped with a rotated or flipped view | -| Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | +| Latency: dab to screen within a frame | partial | #5196 removes the extra frame caused by preparing live brush textures before pointer input; raw-input regression covers press and move. Full latency budgets P13/P16/P33 remain over (scorecard); tablet feel remains open; big-brush lag #2619, #1994; cursor lag #2566, #1343 | | No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | | Live stroke for Brush and Eraser | done | | -| Live stroke for every brush-based retouch tool | missing | TOOL-213-7 | +| Live stroke for every brush-based retouch tool | partial | Mixer Brush now joins the existing live tools (#5196), with incremental dabs, Sample All Layers, and preview pixels reused on release. Spot Healing, Background Eraser and Pattern Stamp remain; TOOL-213-7. | | No OS arrow cursor over the canvas with a pen | partial | TOOL-213-14 | ## Gestures and cursors @@ -91,7 +91,7 @@ tablets against Photoshop side by side. | Tool | Status | Notes | |---|---|---| | Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 | -| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) | +| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | Real live mixing preview and reuse on release (#5196), tested against command pixels and carried paint at 8/16/32-bit in RGB/Gray/CMYK/Lab. Pigment mixing and broader Photoshop fidelity remain open (#2247). | | Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | | Pattern Stamp (aligned, impressionist) | done | | | History Brush | done | Art History Brush missing | @@ -116,6 +116,7 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | #5196: Mixer Brush previews its actual blend while drawing and reuses pixels on release; live brush canvas images include the current frame's input. No global brush-feel estimate or performance budget changed. | | 2026-10-13 | minor | Stale "missing tool" row corrected against main: Color Replacement (`paint.colorReplacement`) is a live toolbar tool; no brush-feel estimate changed | | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87ddef..215f1cc7af5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (G4: live Mixer Brush and same-frame live painting, #5196; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -173,11 +173,17 @@ what an agent can't finish alone. pressure on Windows (#2046) and tap sensitivity (#1933); stair-stepped line weights (#1776), interpolation and smoothing problems (#2138), edge falloff (#2149), pixelated cursor vs Photoshop (#1433), brush rotation not driven by pen rotation (#1301); live preview only for - Brush and Eraser (TOOL-213-7); no image file as a brush tip (TOOL-213-12); per-control pressure + some retouch tools (Spot Healing, Background Eraser, Pattern Stamp; TOOL-213-7); no image file as a brush tip (TOOL-213-12); per-control pressure curves (TOOL-213-11); 4 of Photoshop's symmetry modes. - **Detail:** [`brush-parity.md`](brush-parity.md). - **Estimate:** 100–180 h. **Human:** checking feel on real tablets. +- **2026-10-13 (#5196):** Mixer Brush now renders incremental blended pixels, including Sample All + Layers, and reuses them on release. Live brush canvas preparation follows the current frame's + pointer samples, removing an extra frame of latency. Synthetic tests cover pixels, carried paint, + undo/redo and same-frame texture freshness. Pigment mixing, tablet feel and existing performance + budgets remain open; the G4 estimate is unchanged. + ### G5. Transform, handles and modifiers - **Missing:** the full interpolation list; blend mode/opacity edits during a transform (#1384); path-point nudge; autoscroll at the edge while selecting (#2231); @@ -270,6 +276,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | G4: #5196 live Mixer Brush and removal of the extra frame in live brush rendering. Estimates unchanged. | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f89..8ede545b574 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -138,7 +138,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | -| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | +| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush, Mixer Brush (#1026, #1040, #5196); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Background Eraser, Pattern Stamp | | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3eca518c277..d4d1dfd73b4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-20 · **Change:** minor (#5196: live Mixer Brush and same-frame brush rendering; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | #5196: incremental Mixer Brush previews (including Sample All Layers) reuse their pixels on release; live brush rendering processes the frame's input before preparing the canvas. Synthetic regressions added. Overall scores, pigment mixing, tablet feel and performance budgets remain unchanged. | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf337..eb408bbc6ad 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -52,7 +52,7 @@ id = "TOOL-213-7" target = "Live stroke preview for every brush-based retouch tool" status = "partial" issue = 213 -note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp" +note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush, Mixer Brush (#1026, #1040, #5196); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Background Eraser, Pattern Stamp" [[item]] id = "TOOL-213-8" From 476e41c011ee9b1fe621aaaf394a71f72a5e2883 Mon Sep 17 00:00:00 2001 From: Joe Date: Sun, 18 Oct 2026 15:12:48 +0700 Subject: [PATCH 014/384] feat(psd): Photoshop's Puppet Warp smart filter, drawn with the mesh the file stores A Puppet Warp smart filter (Fltr class rigidTransform) was imported as an unsupported pass-through, so after Replace Contents a design fitted with it (tumbler mockups: a Cylinder warp, then Puppet Warp) came out unbent. Photoshop stores the solved mesh with the pins: originalVertexArray and deformedVertexArray (f32 LE x, y, document pixels) and indexArray (i32 LE, three per triangle) per puppetShape, and the layer corners it was built on (PuX/PuY). Replace Contents keeps that mesh: a dot grid rendered through the filter is the filter-off render mapped through the stored triangles (0.06 px median over 1190 dots). So the mesh is drawn as it is, without solving. - algo: puppet::warp_with_mesh draws a surface through a given mesh; refuses a malformed one or one past MAX_WARP_PIXELS. - engine: a Puppet Warp filter whose params carry a mesh draws it while the filter's pins are the ones it was solved for; otherwise the pins are solved. - io: rigidTransform maps to layer.smartObjects.puppetWarp with pins and mesh, the item kept as a template and written back unchanged while the pins are; a mesh built on other corners than the layer's is dropped at import; malformed data stays an unsupported filter. Mode, density and pin rotation aren't mapped yet. --- crates/algo/src/puppet.rs | 66 ++++++ crates/engine/src/distort_cmds.rs | 42 +++- crates/engine/src/distort_cmds/tests.rs | 33 +++ crates/io/src/puppet_map.rs | 286 ++++++++++++++++++++++++ crates/io/src/smart_map.rs | 33 ++- 5 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 crates/io/src/puppet_map.rs diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index c3c5c3693c2..a6d0c30eeac 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -1280,6 +1280,31 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out) } +/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file +/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored +/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex +/// that isn't finite) or one whose deformed box spans more than [`MAX_WARP_PIXELS`]. +pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option { + let n = mesh.verts.len(); + if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in mesh.verts.iter().chain(deformed) { + if !(p[0].is_finite() && p[1].is_finite()) { + return None; + } + } + for p in deformed { + (x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1])); + } + if (x1 - x0 + 1.0) * (y1 - y0 + 1.0) > MAX_WARP_PIXELS { + return None; + } + let order: Vec = (0..mesh.tris.len()).collect(); + Some(render(src, bounds, mesh, deformed, &order, interp)) +} + /// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same /// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the /// result when none was given. @@ -1430,6 +1455,47 @@ mod tests { assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0])); } + #[test] + fn a_stored_mesh_is_drawn_as_it_is() { + // A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box, + // sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the + // mesh are dropped. + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true)); + for y in 0..48 { + for x in 0..48 { + let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0; + s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 }; + let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0]; + let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect(); + let b = s.content_bounds(); + let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws"); + let ob = out.content_bounds(); + let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0); + for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() { + let w = f64::from(px[0] * px[3]); + let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5); + (sw, sx, sy) = (sw + w, sx + w * x, sy + w * y); + } + let want = map([12.0, 20.0]); + assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]); + // The source column x = 44 is outside the mesh: nothing lands right of the sheared box. + assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}"); + // Malformed meshes are refused rather than drawn. + assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none()); + let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none()); + let mut far = deformed.clone(); + far[2] = [f64::NAN, 0.0]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + far[2] = [1.0e6, 1.0e6]; + assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + let none = PuppetMesh { tris: Vec::new(), ..mesh }; + assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none()); + } + #[test] fn no_pins_or_unmoved_pins_are_identity() { for st in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index ed56e500231..854ac046ee8 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -14,7 +14,7 @@ use std::cell::RefCell; use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell}; use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten}; -use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask}; +use photocraft_algo::puppet::{PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask, warp_with_mesh}; use photocraft_algo::transform::Interp; use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface}; use photocraft_color::PixelFormat; @@ -86,6 +86,34 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result { Ok(PuppetWarp { pins, mode, density, expansion }) } +/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds). +const MAX_STORED_VERTS: usize = 1 << 20; + +/// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts", +/// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of +/// solving again. `None` when there is none, it is malformed, or the filter's pins are no longer +/// the ones it was solved for. +pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> { + let m = p.get("mesh")?; + if m.get("pins") != p.get("pins") { + return None; + } + let points = |k: &str| -> Option> { + let v = m.get(k)?.as_array()?; + if v.len() > MAX_STORED_VERTS { + return None; + } + v.iter().map(|q| serde_json::from_value::<[f64; 2]>(q.clone()).ok()).collect() + }; + let (verts, deformed) = (points("verts")?, points("deformed")?); + let tris = m.get("tris")?.as_array()?; + if tris.len() > 2 * MAX_STORED_VERTS { + return None; + } + let tris: Vec<[usize; 3]> = tris.iter().map(|t| serde_json::from_value(t.clone()).ok()).collect::>()?; + Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed)) +} + /// Perspective Warp params (planes, after the optional straighten). pub fn perspective_params(cmd: &str, p: &Value) -> Result> { let mut planes: Vec = match p.get("planes") { @@ -182,7 +210,17 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect) Some(liquify_surface(surf, &strokes, cell, canvas)) } // A stored warp too far to render leaves the smart object as it is (#1019). - PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())), + PUPPET | PUPPET_SMART => { + // A mesh stored with the filter (from a Photoshop file) is drawn as it is; one that + // can't be drawn leaves the pins to solve. + let b = surf.content_bounds(); + if let Some((mesh, deformed)) = stored_puppet_mesh(params).filter(|_| !b.is_empty()) + && let Some(out) = warp_with_mesh(surf, b, &mesh, &deformed, interp(params)) + { + return Some(out); + } + Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())) + } PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))), _ => None, } diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index 81d17c28c2c..af6d373528a 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -473,3 +473,36 @@ fn unlocked_or_position_locked_smart_objects_take_distortions() { } } } + +#[test] +fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { + // A Photoshop file's Puppet Warp carries its solved mesh: it is drawn as it is (here a whole + // move by (2, 1), although one pin alone would move the block by (6, 4)), until the pins + // change; then the pins are solved. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]}); + let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy); + let p = json!({"pins": pins, "mode": "rigid", "interpolation": "bilinear", "mesh": mesh}); + let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41)); + assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0); + // Pins edited after import: the stored mesh no longer applies. + let mut edited = p.clone(); + edited["mesh"]["pins"][0]["dst"] = json!([40, 29]); + let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + // A malformed mesh is ignored the same way. + for bad in [ + json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}), + json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 9]]}), + ] { + let mut q = p.clone(); + q["mesh"] = bad; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + } +} diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs new file mode 100644 index 00000000000..5758b5d722d --- /dev/null +++ b/crates/io/src/puppet_map.rs @@ -0,0 +1,286 @@ +//! Puppet Warp smart filters (`Fltr` class `rigidTransform`) as observed in Photoshop-authored +//! mockup PSDs. +//! +//! The filter holds a `puppetShapeList` with one `puppetShape` per mesh. Each shape stores its +//! pins (`PinP` where they were placed, `posFinalPins` where they are now, x, y pairs in document +//! pixels) and the mesh Photoshop solved for them, as raw data: +//! +//! ```text +//! originalVertexArray, deformedVertexArray: f32 LE x, y per vertex (document pixels) +//! indexArray: i32 LE, three per triangle +//! ``` +//! +//! and the corners of the layer the mesh was built on (`PuX0..3`, `PuY0..3` on the filter). +//! Replace Contents keeps the mesh: Photoshop redraws the new contents through the stored +//! triangles (measured on a dot grid: 0.1 px mean). So the mesh is imported as the +//! `layer.smartObjects.puppetWarp` filter's `mesh`, which is drawn as it is while the pins are +//! unchanged, and the whole item is kept as a template ([`TEMPLATE_KEY`]) to write back. Pins +//! alone feed PhotoCraft's own solver once they are edited; the mode, density and rotation +//! settings aren't mapped (their stored values aren't identified), so those stay at defaults. + +use photocraft_doc::SmartFilter; +use photocraft_psd::descriptor::{Descriptor, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, set, to_hex}; +use crate::blocks::num; + +pub(super) const CLASS: &str = "rigidTransform"; +pub(super) const COMMAND: &str = "layer.smartObjects.puppetWarp"; +pub(super) const TEMPLATE_KEY: &str = "__puppetPsd"; +/// Far beyond the few hundred vertices Photoshop's meshes have. +const MAX_VERTS: usize = 1 << 20; + +fn raw<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { + match d.get(key)? { + Value::RawData(b) => Some(b), + _ => None, + } +} + +fn floats(d: &Descriptor, key: &str) -> Option> { + let Value::List(v) = d.get(key)? else { return None }; + v.iter().map(|x| num(Some(x)).filter(|f| f.is_finite())).collect() +} + +/// `f32` LE x, y pairs; `None` unless whole, finite and at most [`MAX_VERTS`]. +fn vertices(b: &[u8]) -> Option> { + if !b.len().is_multiple_of(8) || b.len() / 8 > MAX_VERTS { + return None; + } + let f = |c: &[u8]| c.try_into().ok().map(f32::from_le_bytes).filter(|v| v.is_finite()).map(f64::from); + b.as_chunks::<8>().0.iter().map(|c| Some([f(&c[..4])?, f(&c[4..])?])).collect() +} + +/// `i32` LE triples, each index below `n`. +fn triangles(b: &[u8], n: usize) -> Option> { + if !b.len().is_multiple_of(12) || b.len() / 12 > 2 * MAX_VERTS { + return None; + } + let i = |c: &[u8]| c.try_into().ok().map(i32::from_le_bytes).and_then(|v| usize::try_from(v).ok()).filter(|&v| v < n); + b.as_chunks::<12>().0.iter().map(|c| Some([i(&c[..4])?, i(&c[4..8])?, i(&c[8..])?])).collect() +} + +/// One shape's pins (as params), vertices, deformed vertices and triangles. +type Shape = (Vec, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[usize; 3]>); + +/// Pins and mesh of one `puppetShape`, with vertex indices offset by `base`. +fn shape(s: &Descriptor, base: usize) -> Option { + let (from, to) = (floats(s, "PinP")?, floats(s, "posFinalPins")?); + if from.len() != to.len() || !from.len().is_multiple_of(2) { + return None; + } + let pins = from.as_chunks::<2>().0.iter().zip(to.as_chunks::<2>().0).map(|(a, b)| json!({"src": a, "dst": b})).collect(); + let verts = vertices(raw(s, "originalVertexArray")?)?; + let deformed = vertices(raw(s, "deformedVertexArray")?)?; + if verts.len() != deformed.len() { + return None; + } + let tris = triangles(raw(s, "indexArray")?, verts.len())?; + Some((pins, verts, deformed, tris.into_iter().map(|t| t.map(|i| i + base)).collect())) +} + +/// Pins and mesh of a `rigidTransform` descriptor (shapes joined into one mesh), as +/// `{"pins", "mesh"}`. +fn settings(f: &Descriptor) -> Option { + let Some(Value::List(shapes)) = f.get("puppetShapeList") else { return None }; + let (mut pins, mut verts, mut deformed, mut tris) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for s in shapes { + let Value::Descriptor(s) = s else { return None }; + let (p, v, d, t) = shape(s, verts.len())?; + pins.extend(p); + verts.extend(v); + deformed.extend(d); + tris.extend(t); + } + if tris.is_empty() || verts.len() > MAX_VERTS { + return None; + } + let pins = J::Array(pins); + Some(json!({"pins": pins, "mesh": {"pins": pins, "verts": verts, "deformed": deformed, "tris": tris}})) +} + +/// The corners of the layer the mesh was built on (`PuX0`, `PuY0` … `PuX3`, `PuY3`), as x, y +/// pairs. +pub(super) fn built_on(params: &J) -> Option<[f64; 8]> { + let item = template(params).ok()?; + let f = get_desc(&item, "Fltr")?; + let mut q = [0.0; 8]; + for i in 0..4 { + q[2 * i] = num(f.get(&format!("PuX{i}"))).filter(|v| v.is_finite())?; + q[2 * i + 1] = num(f.get(&format!("PuY{i}"))).filter(|v| v.is_finite())?; + } + Some(q) +} + +/// `layer.smartObjects.puppetWarp` params for a Puppet Warp item with a readable mesh. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let mut params = settings(f)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&template.to_bytes()))); + Some(params) +} + +/// The template item of `params`, checked to be a Puppet Warp filter. +fn template(params: &J) -> Result { + let raw = params.get(TEMPLATE_KEY).and_then(J::as_str).ok_or("a Puppet Warp made in PhotoCraft is not written to PSD")?; + let bytes = from_hex(raw).ok_or("the Puppet Warp PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Puppet Warp PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) { + return Err("the Puppet Warp PSD data is not a Puppet Warp filter".into()); + } + Ok(item) +} + +/// The `filterFXList` item: the template, while the pins are still the imported ones. +pub(super) fn export_item(params: &J) -> Result { + let item = template(params)?; + let imported = get_desc(&item, "Fltr").and_then(settings).ok_or("the Puppet Warp PSD data has no readable mesh")?; + if params.get("pins") != imported.get("pins") { + return Err("Puppet Warp pins moved in PhotoCraft are not written to PSD".into()); + } + Ok(item) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter, parse_sold}; + use photocraft_psd::descriptor::{UnicodeString, VersionedDescriptor}; + + const QUAD: [f64; 8] = [10.0, 20.0, 110.0, 22.0, 108.0, 220.0, 12.0, 218.0]; + + fn list(v: &[f64]) -> Value { + Value::List(v.iter().map(|x| Value::Double(*x)).collect()) + } + + fn f32s(v: &[[f32; 2]]) -> Value { + Value::RawData(v.iter().flatten().flat_map(|x| x.to_le_bytes()).collect()) + } + + fn i32s(v: &[i32]) -> Value { + Value::RawData(v.iter().flat_map(|x| x.to_le_bytes()).collect()) + } + + /// A Puppet Warp shape as Photoshop writes it: a box of two triangles with one pin. + fn shape(dx: f32) -> Descriptor { + let verts = [[10.0, 20.0], [110.0, 22.0], [108.0, 220.0], [12.0, 218.0]]; + let moved: Vec<[f32; 2]> = verts.iter().map(|v| [v[0] + dx, v[1]]).collect(); + Descriptor::new("puppetShape") + .with("rigidType", Value::Boolean(true)) + .with("originalVertexArray", f32s(&verts)) + .with("deformedVertexArray", f32s(&moved)) + .with("indexArray", i32s(&[0, 1, 2, 0, 2, 3])) + .with("pinOffsets", list(&[0.0, 0.0])) + .with("posFinalPins", list(&[60.0 + f64::from(dx), 120.0])) + .with("pinVertexIndices", list(&[0.0])) + .with("PinP", list(&[60.0, 120.0])) + .with("meshExpansion", Value::Integer(2)) + } + + fn item(shapes: Vec) -> Descriptor { + let mut f = Descriptor::new(CLASS) + .with("rigidType", Value::Boolean(true)) + .with("puppetShapeList", Value::List(shapes.into_iter().map(Value::Descriptor).collect())); + for i in 0..4 { + f = f.with(&format!("PuX{i}"), Value::Double(QUAD[2 * i])).with(&format!("PuY{i}"), Value::Double(QUAD[2 * i + 1])); + } + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Puppet Warp"))) + .with("blendOptions", blend_options(&neutral)) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(f)) + .with("filterID", Value::Integer(973)) + } + + #[test] + fn puppet_items_map_to_the_command_with_their_mesh() { + let src = item(vec![shape(4.0), shape(-2.0)]); + let f = filter_from_item(&src); + assert_eq!(f.command, COMMAND); + assert_eq!(f.params["pins"], json!([{"src": [60.0, 120.0], "dst": [64.0, 120.0]}, {"src": [60.0, 120.0], "dst": [58.0, 120.0]}])); + let mesh = &f.params["mesh"]; + assert_eq!(mesh["pins"], f.params["pins"]); + assert_eq!(mesh["verts"].as_array().unwrap().len(), 8); + assert_eq!(mesh["verts"][2], json!([108.0, 220.0])); + assert_eq!(mesh["deformed"][2], json!([112.0, 220.0])); + // The second shape's triangles index its own vertices. + assert_eq!(mesh["tris"], json!([[0, 1, 2], [0, 2, 3], [4, 5, 6], [4, 6, 7]])); + assert_eq!(built_on(&f.params), Some(QUAD)); + // Unchanged pins write the original item back; blending is the model's. + assert_eq!(item_for_filter(&f).unwrap(), src); + let mut faded = f.clone(); + faded.opacity = 0.5; + assert_eq!(filter_from_item(&item_for_filter(&faded).unwrap()).opacity, 0.5); + // Moved pins aren't written (the stored mesh would no longer match them). + let mut moved = f.clone(); + moved.params["pins"][0]["dst"] = json!([70.0, 120.0]); + assert!(item_for_filter(&moved).is_err()); + // A Puppet Warp made in PhotoCraft has no template: not written, as before. + let own = SmartFilter { params: json!({"pins": f.params["pins"]}), ..f }; + assert!(item_for_filter(&own).is_err()); + } + + #[test] + fn unreadable_puppet_filters_stay_opaque() { + let with = |key: &str, v: Value| { + let mut s = shape(4.0); + set(&mut s, key, v); + item(vec![s]) + }; + for src in [ + with("originalVertexArray", Value::RawData(vec![0; 31])), + with("originalVertexArray", f32s(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])), + with("deformedVertexArray", f32s(&[[f32::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])), + with("indexArray", i32s(&[0, 1, 4])), + with("indexArray", i32s(&[0, 1, -1])), + with("indexArray", Value::RawData(vec![0; 10])), + with("indexArray", i32s(&[])), + with("posFinalPins", list(&[60.0])), + with("PinP", Value::Integer(3)), + item(vec![]), + ] { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn a_mesh_built_on_other_corners_is_dropped() { + // The mesh is in document pixels where the layer was when it was built: a layer whose + // corners differ from the stored ones keeps the pins but not the mesh. + let sold = |quad: [f64; 8]| { + let fx = Descriptor::new("filterFXStyle") + .with("enab", Value::Boolean(true)) + .with("filterFXList", Value::List(vec![Value::Descriptor(item(vec![shape(4.0)]))])); + let d = Descriptor::new("null").with("Trnf", list(&quad)).with("nonAffineTransform", list(&quad)).with("filterFX", Value::Descriptor(fx)); + let mut v = b"soLD".to_vec(); + v.extend_from_slice(&4u32.to_be_bytes()); + v.extend(VersionedDescriptor::new(d).to_bytes()); + v + }; + let filter = |quad| parse_sold(&sold(quad)).and_then(|p| p.stack).map(|s| s.filters[0].clone()).expect("a stack"); + let kept = filter(QUAD); + assert_eq!(kept.command, COMMAND); + assert!(kept.params.get("mesh").is_some()); + let mut moved = QUAD; + moved[0] += 5.0; + let f = filter(moved); + assert_eq!(f.command, COMMAND); + assert!(f.params.get("mesh").is_none()); + assert_eq!(f.params["pins"], kept.params["pins"]); + // Still written back as it was. + assert_eq!(item_for_filter(&f).unwrap(), item(vec![shape(4.0)])); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 57f8c5d7dae..1bcee752f6b 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -39,6 +39,8 @@ use crate::blocks::{enum_of, get_desc, num}; mod camera_raw; #[path = "displace_map.rs"] mod displace; +#[path = "puppet_map.rs"] +mod puppet; pub use displace::embedded_map as displace_embedded_map; @@ -105,10 +107,32 @@ pub fn parse_sold(data: &[u8]) -> Option { let d = sold_descriptor(data)?; let (idnt, transform) = crate::blocks::parse_smart(b"SoLd", data); let placed = text_of(&d, "placed").unwrap_or_default(); - let stack = get_desc(&d, "filterFX").map(filter_stack); + let mut stack = get_desc(&d, "filterFX").map(filter_stack); + if let Some(stack) = &mut stack { + drop_moved_puppet_meshes(stack, &d); + } Some(Placed { descriptor: d, idnt, placed, transform, stack }) } +/// A Puppet Warp's stored mesh sits where the layer was when it was built (document pixels); one +/// built on other corners than the layer's own doesn't fit it, so its pins are solved instead. +fn drop_moved_puppet_meshes(stack: &mut FilterStack, d: &Descriptor) { + let corners = |k: &str| match d.get(k) { + Some(Value::List(v)) if v.len() == 8 => v.iter().map(|x| num(Some(x))).collect::>>(), + _ => None, + }; + let quad = corners("nonAffineTransform").or_else(|| corners("Trnf")); + for f in stack.filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + let fits = match (&quad, puppet::built_on(&f.params)) { + (Some(q), Some(b)) => q.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01), + _ => false, + }; + if !fits && let Some(p) = f.params.as_object_mut() { + p.remove("mesh"); + } + } +} + /// Reads a `filterFX` object. pub fn filter_stack(fx: &Descriptor) -> FilterStack { let filters = match fx.get("filterFXList") { @@ -469,6 +493,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let known = match class.as_deref() { Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + Some(puppet::CLASS) => puppet::import_params(item).map(|p| (puppet::COMMAND, p)), Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)), c => c.and_then(|c| known_params(c, fltr)), }; @@ -624,6 +649,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { if f.command == GALLERY_COMMAND { return gallery_item(f); } + if f.command == puppet::COMMAND && f.params.get(puppet::TEMPLATE_KEY).is_some() { + let mut d = puppet::export_item(&f.params)?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; From b45be327b66dd4f31c82d65ffa52e9c90f806966 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 11:37:15 +0200 Subject: [PATCH 015/384] Address CodeRabbit review: count line continuations in string literals A backslash before a newline inside a string skipped the newline without counting it, so every later violation reported a line one too low. Co-Authored-By: Claude Opus 5.5 --- xtask/src/panel_commands.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index ec76ff9f867..82da2154f49 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -160,7 +160,13 @@ fn lex(src: &str) -> Vec { i += if ch == '"' { 1 } else { 2 }; while i < c.len() { match at(i) { - '\\' => i += 2, + '\\' => { + // A line continuation (`\` before a newline) still ends a source line. + if at(i + 1) == '\n' { + line += 1; + } + i += 2; + } '"' => { i += 1; break; @@ -576,6 +582,25 @@ fn panel(app: &mut App, ui: &mut Ui) { assert!(v[0].what.contains("active_mut")); } + #[test] + fn line_continuations_in_strings_keep_line_numbers() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + ui.label(\"first \\ + second \\ + third\"); + if ui.button(\"Hide\").clicked() { + if let Some(st) = app.session.active_mut() { + st.active_layer = None; + } + } +} +"; + let v = scan("f.rs", src, &[]); + assert_eq!(v.len(), 1); + assert_eq!(v[0].line, 7); + } + #[test] fn every_mutation_kind_and_trigger_is_caught() { let src = r#" From 9b213580353b7a8adeb9b28a0ccf0246b3abc3e4 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 21 Oct 2026 12:43:58 +0200 Subject: [PATCH 016/384] Address CodeRabbit review: document the panel scan's blind spots Co-Authored-By: Claude Opus 5.5 --- docs/development.md | 7 ++++++- xtask/src/panel_commands.rs | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index d4e42d83ede..6167239639d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -307,7 +307,12 @@ widget function that takes a `Ui` and returns `bool`. Inside one it fails, with direct document or session change: `active_mut()`, `Arc::make_mut` on a doc, `session.undo/redo/select_layer/add_document/close(…)` or an assignment to a session field (`app.session.tools.foreground = …`). Dispatch the command instead (`app.run`, `menus::invoke`). -The scanner is lexical and does not follow calls into helpers. Existing violations are listed in +A passing scan is not complete coverage. The scanner is lexical: it does not follow calls into +helpers, and it only recognises an `if` block (a `match` arm guard with a block counts) or a +`let` local holding a response, so a `match` on a response without such a guard, a response kept +in a struct field and tested later, or a closure callback is not checked. Preferences +(`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and switching the active document +are out of scope. Existing violations are listed in `ALLOWLIST` in `xtask/src/panel_commands.rs` by file and function, each with its reason; an entry that matches nothing any more is an error, so the list only shrinks. diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 82da2154f49..76d28a83e53 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -17,8 +17,11 @@ //! * test code is skipped: `*_tests.rs`, `tests.rs`, files under `tests/` and `#[cfg(test)]` items. //! //! Out of scope: preferences (`edit_prefs`), shell view state (`app.ui`, the `UI_COMMANDS`) and -//! switching the active document. Calls into helper functions are not followed, so a handler that calls a helper which mutates the -//! document passes; the check catches the inline pattern, which is how such shortcuts start. +//! switching the active document. Calls into helper functions are not followed, so a handler +//! that calls a helper which mutates the document passes; the check catches the inline pattern, +//! which is how such shortcuts start. Only `if` blocks (also a `match` arm guard with a block) +//! and `let` locals count as handlers: a `match` on a response without one, a response kept in a +//! struct field and tested later, or a closure callback is not scanned. //! //! Existing violations are listed in [`ALLOWLIST`] by file and enclosing function, each with the //! reason it is still there. An entry that no longer matches anything is an error, so the list only @@ -601,6 +604,21 @@ fn panel(app: &mut App, ui: &mut Ui) { assert_eq!(v[0].line, 7); } + #[test] + fn a_match_arm_guard_with_a_block_is_a_click_handler() { + let src = " +fn panel(app: &mut App, ui: &mut Ui) { + match ui.button(\"Hide\") { + r if r.clicked() => { + app.session.active_mut(); + } + _ => {} + } +} +"; + assert_eq!(lines(src), [(5, "panel".to_string())]); + } + #[test] fn every_mutation_kind_and_trigger_is_caught() { let src = r#" From 558217792eb1521024d0c0e2d83ae248034caa1b Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:19:33 +0300 Subject: [PATCH 017/384] fix(ui): live preview in the Fade dialog (#8230) Edit > Fade opens with a Preview checkbox, as in Photoshop, and the canvas shows the layer faded with the dialog's opacity and mode. Fade blends against the state before the last step, which only the session's history holds, so the preview comes from a new engine helper, edit_menu_cmds::fade_preview, that runs the same blend on a copy of the document without a history step; fade() now shares its setup and blend with it. --- crates/engine/src/edit_menu_cmds.rs | 47 +++++++++++++++++++++++------ crates/ui-egui/src/canvas.rs | 18 ++++++++++- crates/ui-egui/src/filter_dialog.rs | 2 ++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index b620230a619..6bc9632b22c 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -202,7 +202,17 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble Ok(()) } -fn fade(s: &mut Session, p: &Value) -> Result { +/// What Edit › Fade with `p` blends: the layer (or mask) of the last step, its pixels before it, +/// the area to blend, the mode and the opacity. +struct FadeJob { + src: FadeSource, + before: Surface, + area: Rect, + mode: BlendMode, + opacity: f32, +} + +fn fade_job(s: &Session, p: &Value) -> Result { let cmd = "edit.fade"; let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?; let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0; @@ -217,15 +227,32 @@ fn fade(s: &mut Session, p: &Value) -> Result { // A uniform mask can change every pixel without storing a single tile. In that case // the content bounds are empty, but Fade must still blend across the canvas. let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) }; - let label = format!("Fade {}", src.label); - s.edit(&label, |doc, _| { - let l = doc.layer_mut(src.layer).ok_or(EngineError::NoLayer(src.layer))?; - let surf = if src.mask { l.mask.as_mut().map(|m| &mut m.surface) } else { l.surface_mut() }.ok_or(EngineError::NoLayer(src.layer))?; - fade_surface(&before, surf, area, mode, opacity)?; - surf.prune(); - Ok(()) - })?; - Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()})) + Ok(FadeJob { src, before, area, mode, opacity }) +} + +fn apply_fade(doc: &mut Document, j: &FadeJob) -> Result<()> { + let l = doc.layer_mut(j.src.layer).ok_or(EngineError::NoLayer(j.src.layer))?; + let surf = if j.src.mask { l.mask.as_mut().map(|m| &mut m.surface) } else { l.surface_mut() }.ok_or(EngineError::NoLayer(j.src.layer))?; + fade_surface(&j.before, surf, j.area, j.mode, j.opacity)?; + surf.prune(); + Ok(()) +} + +fn fade(s: &mut Session, p: &Value) -> Result { + let j = fade_job(s, p)?; + let label = format!("Fade {}", j.src.label); + s.edit(&label, |doc, _| apply_fade(doc, &j))?; + Ok(json!({"faded": j.src.label, "opacity": j.opacity * 100.0, "mode": j.mode.label()})) +} + +/// The active document as Edit › Fade with `p` would leave it, without a history step: the +/// Fade dialog's live preview (#8230). `Err` when Fade isn't available or `p` is invalid. +pub fn fade_preview(s: &Session, p: &Value) -> Result { + can_fade(s).map_err(|e| bad("edit.fade", e))?; + let j = fade_job(s, p)?; + let mut doc = Document::clone(&s.active().ok_or(EngineError::NoDocument)?.doc); + apply_fade(&mut doc, &j)?; + Ok(doc) } // ------------------------------------------------------------------ Purge diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2e535f3f64..f4a1b91074c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1825,7 +1825,8 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e params = crate::select_mask_view::request_params(&d.fields, params); } let st = app.session.documents().get(idx)?; - let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); + let fade = cmd == "edit.fade"; + let k = if fade { 1 } else { filter_preview_factor(app, &st.doc, &cmd, ¶ms) }; let request = crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, @@ -1837,6 +1838,21 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e k, }; let key = request.doc.0 ^ (1u64 << 61); + // Fade blends the layer back towards its pixels before the last step, which only the + // session's history holds: one blend over the changed area at full size, computed here (#8230). + if fade { + if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { + let t0 = crate::gpu_canvas::now_ms(); + let computed = photocraft_engine::edit_menu_cmds::fade_preview(&app.session, &request.params).ok().map(|doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }); + publish_filter_preview(app, idx, request, computed, crate::gpu_canvas::now_ms() - t0); + } + let preview = app.filter_preview.as_ref()?; + let result = preview.result.as_ref()?; + return Some((preview.key.k, key, [result.size.width, result.size.height])); + } // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at // 1000 px takes long enough to freeze the window. Once the slider settles, a preview still // running for an older value is cancelled. diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index a908df5eba3..ad60a732faa 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -289,6 +289,8 @@ pub const PREVIEWED: &[&str] = &[ // The preview shows the image through the chosen profile, as Photoshop's Preview does. "edit.assignProfile", "edit.convertToProfile", + // Previewed from the session's history (`canvas::ensure_filter_preview`), not on a proxy. + "edit.fade", ]; pub fn has_dialog(command: &str) -> bool { From c3a99fc8fd7c4307235f57ae608ef1b7a6d7edbb Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:48:55 +0300 Subject: [PATCH 018/384] test(ui): the Fade dialog previews the faded layer without a step (#8230) --- crates/engine/src/edit_menu_cmds/tests.rs | 27 +++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 26 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 16e98b87195..f7362d28b27 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -17,6 +17,33 @@ fn active(s: &Session) -> &Layer { st.doc.layer(st.active_layer.unwrap()).unwrap() } +/// #8230: the Fade dialog's preview is the document Fade would leave, at every depth and in a +/// blend mode, with no history step and nothing changed until OK; with nothing to fade or a bad +/// mode it is an error, not a panic. +#[test] +fn fade_preview_matches_fade_without_a_step() { + for depth in [8, 16, 32] { + assert!(fade_preview(&session(depth), &json!({"opacity": 50})).is_err(), "nothing to fade yet"); + for p in [json!({"opacity": 25}), json!({"opacity": 60, "mode": "difference"}), json!({})] { + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + let (steps, revision) = (s.active().unwrap().history.past_len(), s.active().unwrap().revision); + let preview = fade_preview(&s, &p).unwrap(); + assert_eq!((s.active().unwrap().history.past_len(), s.active().unwrap().revision), (steps, revision), "depth {depth} {p}: no step"); + assert_eq!(px(&s, 5, 5)[0], 0.0, "depth {depth} {p}: the layer is untouched"); + let id = s.active().unwrap().active_layer.unwrap(); + let shown = preview.layer(id).unwrap().surface().unwrap().pixel(5, 5); + s.execute("edit.fade", p.clone()).unwrap(); + assert_eq!(shown, px(&s, 5, 5), "depth {depth} {p}: the preview is what OK applies"); + } + let mut s = session(depth); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + assert!(fade_preview(&s, &json!({"mode": "nonsense"})).is_err()); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(fade_preview(&s, &json!({})).is_err(), "not right after the step"); + } +} + #[test] fn fade_blends_last_step_at_all_depths() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f4a1b91074c..c25b5a07e47 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6333,6 +6333,32 @@ mod tests { assert_eq!(app.perf.frame_refresh_ms, app.perf.composite_ms + app.perf.upload_ms); } + /// #8230: Edit › Fade opens with a Preview checkbox, as in Photoshop, and the canvas shows the + /// layer faded with the dialog's opacity, at full size, on the desktop and the inline path + /// alike. The document and its history stay as they were until OK. + #[test] + fn fade_dialog_previews_the_faded_layer() { + for background in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = background; + app.run("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + let id = crate::filter_dialog::open(&mut app, "edit.fade").expect("Fade has a dialog"); + let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).expect("the dialog"); + assert_eq!(d.fields.get("__preview"), Some(&json!(true)), "a Preview checkbox, on"); + d.fields.insert("opacity".into(), json!(25)); + let steps = app.session.active().unwrap().history.past_len(); + let (k, _, size) = ensure_filter_preview(&mut app, 0, &egui::Context::default()).expect("a preview"); + assert_eq!((k, size), (1, [40, 30]), "background jobs {background}"); + let st = app.session.active().unwrap(); + let layer = st.active_layer.unwrap(); + let shown = app.filter_preview.as_ref().and_then(|p| p.result.as_ref()).expect("a result").layer(layer).unwrap().surface().unwrap().pixel(3, 3); + assert!((shown[0] - 0.75).abs() < 0.01, "background jobs {background}: {shown:?}"); + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3)[0], 0.0, "the layer itself is untouched"); + assert_eq!(st.history.past_len(), steps, "no history step"); + } + } + /// #6326: a filter's live preview is never coarser than the screen. On a document big enough /// for a reduced proxy, the view at 100 % previews at full resolution, and only a view zoomed /// out far enough uses the proxy. Through `ensure_filter_preview`, so the factor the canvas From c76e473450ece413ca8111871ea05efcbf516ea0 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:36:01 -0400 Subject: [PATCH 019/384] Refresh deferred canvas camera after navigation input --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/tests/live_stroke_canvas.rs | 63 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5da81fede6..f8c3dcb32fa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4042,7 +4042,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.request_repaint(); } } - let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf }; + let xf = if paint_after_input { ViewXform { zoom: view.zoom / ppp, center: view.center, rotation: view.rotation, ..xf } } else { xf }; let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf)); // A Hand drag (the tool, Space or the middle button) keeps every overlay on screen, as in // Photoshop: the Pen path being drawn, paths, guides, grid, transform box (#6592). Only the diff --git a/crates/ui-egui/tests/live_stroke_canvas.rs b/crates/ui-egui/tests/live_stroke_canvas.rs index 136c3678980..ddda92ff64a 100644 --- a/crates/ui-egui/tests/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/live_stroke_canvas.rs @@ -235,3 +235,66 @@ fn refine_edge_brush_stays_visible_above_the_canvas_with_live_tools() { } } } + +/// Deferred live-tool painting uses the camera changed by this frame's navigation input. +#[test] +fn live_tool_camera_navigation_is_visible_in_the_input_frame() { + use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); + for flip in [false, true] { + for tool in [Tool::Brush, Tool::MixerBrush] { + for zoom in [false, true] { + let Some(mut h) = harness() else { return }; + let app = h.state_mut(); + app.run("file.new", json!({"width":300,"height":200,"background":"white"})).unwrap(); + app.run("paint.stroke", json!({"points":[[40,40],[240,160]],"size":30,"hardness":1.0,"color":"#2050df"})).unwrap(); + app.run("prefs.set", json!({"values":{"tools.overscroll":true,"interface.enableAnimations":false,"general.zoomWithScrollWheel":false}})) + .unwrap(); + app.ui.tool = tool; + app.ui.view.flip_horizontal = flip; + app.sync_views(); + h.run_steps(4); + control(&mut h, "ui.set", json!({"zoom":1.0,"center":[150,100]})); + let at = h.state().last_canvas_rect.center() + egui::vec2(30.0, 10.0); + h.hover_at(at); + h.run_steps(2); + let before = h.state().ui.views[0].clone(); + h.input_mut().events.push(if zoom { + egui::Event::Zoom(1.25) + } else { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(30.0, 20.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + } + }); + h.step(); + let moved = h.state().ui.views[0].clone(); + assert!(if zoom { moved.zoom != before.zoom } else { moved.center != before.center }, "navigation was applied"); + let immediate = h.render().unwrap(); + let rect = h.state().last_canvas_rect.shrink(8.0); + h.step(); + let settled = h.render().unwrap(); + if tool == Tool::MixerBrush + && flip + && zoom + && let Ok(path) = std::env::var("PHOTOCRAFT_NAV_SNAPSHOT") + { + settled.save(path).unwrap(); + } + assert_eq!(h.state().ui.views[0].zoom, moved.zoom); + assert_eq!(h.state().ui.views[0].center, moved.center); + let mut diff = 0; + for y in rect.top() as u32..rect.bottom() as u32 { + for x in rect.left() as u32..rect.right() as u32 { + for (a, b) in immediate.get_pixel(x, y).0.into_iter().zip(settled.get_pixel(x, y).0) { + diff = diff.max(a.abs_diff(b)); + } + } + } + assert!(diff <= 1, "{tool:?} flip={flip} zoom={zoom}: canvas changes {diff}/255 one frame after navigation"); + } + } + } +} From eaac405f5629a8e3b221a59bbb97c224dca36054 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 22 Oct 2026 10:50:35 +0700 Subject: [PATCH 020/384] fix(psd): a stored Puppet Warp mesh is drawn only while the filter and placement are unedited The mesh a Photoshop file stores was drawn whenever the pins were unchanged: - a solver setting (mode, density, expansion) set after import had no effect and was lost on save, since imported params set none. A set one now counts as an edit: the pins are solved, and export refuses it like moved pins. - the mesh is in document pixels where the layer was on import, but was still drawn after the smart object was moved, transformed or warped, leaving pixels outside its triangles missing. Import now records the placement (SmartObject::placement_json: transform, perspective, warp) on the mesh, and the smart filter replay drops a mesh made for another placement. Replace Contents keeps the placement, so mockups still use the mesh. --- crates/doc/src/lib.rs | 6 ++++ crates/engine/src/distort_cmds.rs | 22 +++++++++++++-- crates/engine/src/distort_cmds/tests.rs | 37 ++++++++++++++++++++++++- crates/engine/src/smart_cmds.rs | 9 +++--- crates/io/src/psd_import.rs | 6 ++-- crates/io/src/puppet_map.rs | 21 ++++++++++++++ crates/io/src/smart_map.rs | 14 +++++++++- 7 files changed, 104 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 268b0b6b437..51acf288b9b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -547,6 +547,12 @@ impl SmartObject { perspective: None, } } + + /// Where the placed source lands (transform, perspective and warp), as JSON: the placement a + /// smart filter stored in document pixels (an imported Puppet Warp mesh) was made for. + pub fn placement_json(&self) -> serde_json::Value { + serde_json::json!({"transform": self.transform.m, "perspective": self.perspective, "warp": self.warp}) + } } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index 854ac046ee8..2a2d965f08f 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -91,11 +91,12 @@ const MAX_STORED_VERTS: usize = 1 << 20; /// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts", /// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of -/// solving again. `None` when there is none, it is malformed, or the filter's pins are no longer -/// the ones it was solved for. +/// solving again. `None` when there is none, it is malformed, or the filter was edited since: its +/// pins are no longer the ones it was solved for, or a solver setting is set (imported params +/// set none). pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> { let m = p.get("mesh")?; - if m.get("pins") != p.get("pins") { + if m.get("pins") != p.get("pins") || ["mode", "density", "expansion"].iter().any(|k| p.get(*k).is_some()) { return None; } let points = |k: &str| -> Option> { @@ -114,6 +115,21 @@ pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> { Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed)) } +/// `params` of smart filter `command` on `sm`, without a stored Puppet Warp mesh made for another +/// placement than `sm`'s (`mesh.placement`, recorded on import): a mesh in document pixels doesn't +/// fit a smart object moved, transformed or warped since, so its pins are solved instead. +pub fn placed_puppet_params<'a>(command: &str, params: &'a Value, sm: &photocraft_doc::SmartObject) -> std::borrow::Cow<'a, Value> { + let stale = command == PUPPET_SMART && params.get("mesh").is_some_and(|m| m.get("placement") != Some(&sm.placement_json())); + if !stale { + return std::borrow::Cow::Borrowed(params); + } + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("mesh"); + } + std::borrow::Cow::Owned(p) +} + /// Perspective Warp params (planes, after the optional straighten). pub fn perspective_params(cmd: &str, p: &Value) -> Result> { let mut planes: Vec = match p.get("planes") { diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index af6d373528a..851b7858146 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -486,7 +486,7 @@ fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]}); let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy); - let p = json!({"pins": pins, "mode": "rigid", "interpolation": "bilinear", "mesh": mesh}); + let p = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap(); assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41)); assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0); @@ -495,6 +495,13 @@ fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { edited["mesh"]["pins"][0]["dst"] = json!([40, 29]); let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap(); assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); + // So does a solver setting set after import (imported params set none). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut q = p.clone(); + q[k] = v; + let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap(); + assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44), "{k}"); + } // A malformed mesh is ignored the same way. for bad in [ json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}), @@ -506,3 +513,31 @@ fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() { assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44)); } } + +#[test] +fn a_stored_puppet_mesh_is_drawn_only_on_the_placement_it_was_made_for() { + // The mesh sits in document pixels where the layer was on import (`mesh.placement`); once the + // smart object is moved, transformed or warped, its pins are solved instead. + let surf = active_surface(&session(8)); + let canvas = Rect::new(0, 0, 80, 60); + let pins = json!([{"src": [30, 25], "dst": [36, 29]}]); + let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]]; + let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect(); + let src = photocraft_doc::SmartSource::Linked { path: "x.png".into() }; + let mut sm = photocraft_doc::SmartObject::new(src, photocraft_geom::Affine::IDENTITY, None); + let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]], "placement": sm.placement_json()}); + let params = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh}); + sm.smart_filters.push(SmartFilter { command: PUPPET_SMART.into(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }); + let render = |sm: &photocraft_doc::SmartObject| crate::smart_cmds::apply_smart_filters(&surf, sm, canvas).unwrap().content_bounds(); + assert_eq!(render(&sm), Rect::new(12, 11, 62, 41), "the stored mesh"); + let mut placed = sm.clone(); + placed.transform = photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] }; + assert_eq!(render(&placed), Rect::new(16, 14, 66, 44), "moved: solved"); + let mut warped = sm.clone(); + warped.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 50.0, 30.0])); + assert_eq!(render(&warped), Rect::new(16, 14, 66, 44), "warped: solved"); + // A mesh with no placement recorded is treated the same way. + let mut bare = sm.clone(); + bare.smart_filters[0].params["mesh"].as_object_mut().unwrap().remove("placement"); + assert_eq!(render(&bare), Rect::new(16, 14, 66, 44), "no placement: solved"); +} diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 3ca10083b9c..6ae862ce511 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -314,7 +314,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, canvas, extent)?; + cur = apply_smart_filter(f, sm, &cur, canvas, extent)?; } Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), @@ -325,8 +325,9 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> /// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the /// requesting session's authority denies the filter's params: callers surface the error instead of /// rendering (and caching) unfiltered pixels. -fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result { - let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? { +fn apply_smart_filter(f: &SmartFilter, sm: &SmartObject, cur: &Surface, canvas: Rect, extent: Rect) -> Result { + let params = crate::distort_cmds::placed_puppet_params(&f.command, &f.params, sm); + let out = match crate::filters::apply_filter_to_surface(&f.command, ¶ms, cur, canvas, None, extent)? { // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. Some(out) => out, None => cur.clone(), @@ -427,7 +428,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result { } _ => stored, }; - LayerContent::Smart(SmartObject { + let mut sm = SmartObject { contents_id, source: SmartSource::Linked { path: id }, transform, @@ -429,7 +429,9 @@ impl Ctx<'_> { warp, stack_mode: None, perspective, - }) + }; + crate::smart_map::pin_puppet_meshes(&mut sm); + LayerContent::Smart(sm) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { // A shape whose vector mask has a density or feather is a fill layer seen through a // soft vector mask: the fill shows beyond the path, which the stored pixels (the diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs index 5758b5d722d..c5581d34045 100644 --- a/crates/io/src/puppet_map.rs +++ b/crates/io/src/puppet_map.rs @@ -147,6 +147,10 @@ pub(super) fn export_item(params: &J) -> Result { if params.get("pins") != imported.get("pins") { return Err("Puppet Warp pins moved in PhotoCraft are not written to PSD".into()); } + // Imported params set no solver settings, so a set one is an edit the template doesn't hold. + if let Some(k) = ["mode", "density", "expansion"].into_iter().find(|k| params.get(*k).is_some()) { + return Err(format!("the Puppet Warp `{k}` set in PhotoCraft is not written to PSD")); + } Ok(item) } @@ -226,6 +230,23 @@ mod tests { let mut moved = f.clone(); moved.params["pins"][0]["dst"] = json!([70.0, 120.0]); assert!(item_for_filter(&moved).is_err()); + // Nor are solver settings set after import (the template holds the imported ones). + for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] { + let mut edited = f.clone(); + edited.params[k] = v; + assert!(item_for_filter(&edited).is_err(), "{k}"); + } + // The import records the placement the mesh was solved for; the item written back is + // unchanged by it. + let mut sm = photocraft_doc::SmartObject::new( + photocraft_doc::SmartSource::Linked { path: String::new() }, + photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, + None, + ); + sm.smart_filters.push(f.clone()); + crate::smart_map::pin_puppet_meshes(&mut sm); + assert_eq!(sm.smart_filters[0].params["mesh"]["placement"], sm.placement_json()); + assert_eq!(item_for_filter(&sm.smart_filters[0]).unwrap(), src); // A Puppet Warp made in PhotoCraft has no template: not written, as before. let own = SmartFilter { params: json!({"pins": f.params["pins"]}), ..f }; assert!(item_for_filter(&own).is_err()); diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 1bcee752f6b..a89d3f6e998 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -23,7 +23,7 @@ //! (Displace's carries the embedded displacement map, see `displace_map.rs`). use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{LayerMask, SmartFilter}; +use photocraft_doc::{LayerMask, SmartFilter, SmartObject}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use photocraft_geom::{Affine, Rect as GeomRect}; use photocraft_psd::Compression; @@ -133,6 +133,18 @@ fn drop_moved_puppet_meshes(stack: &mut FilterStack, d: &Descriptor) { } } +/// Records on each imported Puppet Warp mesh the placement it was solved for (`mesh.placement`, +/// [`SmartObject::placement_json`]): once the smart object is moved, transformed or warped, the +/// mesh no longer fits it and the pins are solved instead. +pub(crate) fn pin_puppet_meshes(sm: &mut SmartObject) { + let placement = sm.placement_json(); + for f in sm.smart_filters.iter_mut().filter(|f| f.command == puppet::COMMAND) { + if let Some(m) = f.params.get_mut("mesh").and_then(J::as_object_mut) { + m.insert("placement".into(), placement.clone()); + } + } +} + /// Reads a `filterFX` object. pub fn filter_stack(fx: &Descriptor) -> FilterStack { let filters = match fx.get("filterFXList") { From 6dacefce4d5ffefeb6676bbe689a4c1ba250ed26 Mon Sep 17 00:00:00 2001 From: GyoJoong Kum Date: Thu, 22 Oct 2026 07:11:47 +0000 Subject: [PATCH 021/384] fix: remember foreground and background colors across launches --- crates/engine/src/prefs.rs | 3 ++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 79 +++++++++++++++++++++++++++++++ crates/ui-egui/src/tool_memory.rs | 49 ++++++++++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index f317ebeca60..1540dc1a587 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1008,6 +1008,9 @@ pub struct Preferences { /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, + /// Foreground/background swatches for pixels and masks, restored by the shell at launch. + /// Null in older files, which retain the default black/white pixel colours. + pub tool_colors: Value, /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To /// targets that differ from their defaults, saved as the user toggles them and restored at /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2e795430c4..4d71ed179a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2059,7 +2059,7 @@ impl PhotocraftApp { impl eframe::App for PhotocraftApp { fn on_exit(&mut self) { if let Err(err) = tool_memory::save_on_exit(self) { - log::warn!("Preferences: could not save the final tool selection: {err}"); + log::warn!("Preferences: could not save the final tool state: {err}"); } } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index b9ed130839e..ced818bee2f 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -53,6 +53,7 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + pub(crate) tool_colors: Option<[[[f32; 4]; 2]; 2]>, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, } @@ -3499,6 +3500,84 @@ mod tests { assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && app3.ui.brush_picker_list.show_tip); } + #[test] + fn tool_colors_survive_restart_before_the_first_frame() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let fg = [0.2, 0.4, 0.6, 0.8]; + let bg = [0.9, 0.7, 0.3, 1.0]; + app.run("tools.setColors", json!({"foreground": fg, "background": bg})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + let rev = app.session.prefs.rev(); + crate::tool_memory::persist(&mut app, &ctx); + assert_eq!(app.session.prefs.rev(), rev, "unchanged colours do not rewrite preferences"); + } + + #[test] + fn tool_colors_preserve_finite_hdr_components() { + let (mut app, store) = app_with_store(); + let fg = [1.5, -0.1, 0.6, 1.0]; + app.run("tools.setColors", json!({"foreground": fg})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, fg); + } + + #[test] + fn tool_colors_flush_swaps_and_defaults_on_exit_without_another_frame() { + let (mut app, store) = app_with_store(); + app.run("tools.setColors", json!({"foreground": "#336699", "background": "#cc8844"})).unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, restored_store) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, app.session.tools.foreground); + assert_eq!(restored.session.tools.background, app.session.tools.background); + restored.run("tools.defaultColors", json!({})).unwrap(); + crate::tool_memory::save_on_exit(&mut restored).unwrap(); + let (defaults, _) = app_with_saved(restored_store.lock().unwrap().clone()); + assert_eq!(defaults.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(defaults.session.tools.background, [1.0; 4]); + } + + #[test] + fn tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + app.session.tools.background = [0.9, 0.7, 0.3, 1.0]; + let pixels = [app.session.tools.foreground, app.session.tools.background]; + app.session.tools.target_mask(true); + app.session.tools.foreground = [0.5, 0.5, 0.5, 1.0]; + let masks = [app.session.tools.foreground, app.session.tools.background]; + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], pixels); + restored.session.tools.target_mask(true); + assert_eq!([restored.session.tools.foreground, restored.session.tools.background], masks); + } + + #[test] + fn tool_colors_ignore_invalid_swatches_without_losing_valid_preferences() { + for bad in [json!(null), json!("red"), json!([1, 0, 0]), json!([1e100, 0, 0, 1]), json!([0, "bad", 0, 1])] { + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": bad, "background": [0.2, 0.4, 0.6, 1.0]}, + "toolOptions": {"feather": 12.0} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.ui.tool_options.feather, 12.0); + } + let (app, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.0}}).to_string())); + assert_eq!(app.session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(app.session.tools.background, [1.0; 4]); + } + #[test] fn selected_tool_survives_a_restart_without_option_changes() { use crate::state::Tool; diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 60895bb097a..492f8534e7c 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -1,4 +1,4 @@ -//! Selected tool and tool options remembered between launches, as in Photoshop (#2814). +//! Selected tool, colours and tool options remembered between launches, as in Photoshop (#2814). //! //! `Preferences::tool_options` holds the [`ToolOptions`] fields that differ from their defaults, //! so a default changed in a later release still reaches users who never touched that option. @@ -68,6 +68,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_colors(app, ctx); persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; @@ -79,6 +80,49 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Keep pixel and mask swatches separately, even when the app exits with a mask targeted. +fn colors(app: &PhotocraftApp) -> [[[f32; 4]; 2]; 2] { + let t = &app.session.tools; + let current = [t.foreground, t.background]; + if t.mask_targeted { [t.other_colors, current] } else { [current, t.other_colors] } +} + +fn persist_colors(app: &mut PhotocraftApp, ctx: &egui::Context) { + let now = colors(app); + if app.prefs_rt.tool_colors == Some(now) { + return; + } + app.prefs_rt.tool_colors = Some(now); + let [[foreground, background], [mask_foreground, mask_background]] = now; + app.session.prefs.edit(|p| { + p.tool_colors = json!({ + "foreground": foreground, "background": background, + "maskForeground": mask_foreground, "maskBackground": mask_background, + }); + }); + // Panels and shortcuts can change colours after prefs_ui::tick already ran this frame. + ctx.request_repaint(); +} + +fn restore_colors(app: &mut PhotocraftApp) { + let saved = &app.session.prefs().tool_colors; + let mut pairs = colors(app); + let [[foreground, background], [mask_foreground, mask_background]] = &mut pairs; + for (key, color) in [("foreground", foreground), ("background", background), ("maskForeground", mask_foreground), ("maskBackground", mask_background)] { + if let Some(v) = saved.get(key) { + match serde_json::from_value::<[f32; 4]>(v.clone()) { + Ok(c) if c.iter().all(|x| x.is_finite()) => *color = c, + _ => log::warn!("Preferences: ignoring the remembered `{key}` colour"), + } + } + } + let [pixels, mask] = pairs; + let (current, other) = if app.session.tools.mask_targeted { (mask, pixels) } else { (pixels, mask) }; + [app.session.tools.foreground, app.session.tools.background] = current; + app.session.tools.other_colors = other; + app.prefs_rt.tool_colors = Some(pairs); +} + /// Remember the painting tools' own brushes when they change (a compare per frame otherwise: /// tip bitmaps are shared, so equal brushes compare by pointer). A brush that can't be remembered /// yet (a sampled tip too big to embed, before the preset store takes it) keeps what was saved. @@ -153,7 +197,7 @@ fn restore_brushes(app: &mut PhotocraftApp) { app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); } -/// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +/// Flush the final tool state on normal exit, even if no further frame ran after a change. /// The brush first, as in a frame, so the saved brush owner matches the saved brush. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { let ctx = egui::Context::default(); @@ -195,6 +239,7 @@ pub fn restore(app: &mut PhotocraftApp) { log::warn!("Preferences: the remembered tool options are not an object; using the defaults"); } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); + restore_colors(app); restore_brushes(app); } From cbb0406c2b2b9ee7caaf8c0234f03f44827af098 Mon Sep 17 00:00:00 2001 From: GyoJoong Kum Date: Thu, 22 Oct 2026 07:35:50 +0000 Subject: [PATCH 022/384] fix(ui): preserve CMYK picks and complete color persistence docs --- crates/engine/src/lib.rs | 2 +- crates/engine/src/prefs.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 71 +++++++++++++++++++++++++++++++ crates/ui-egui/src/tool_memory.rs | 31 +++++++++++++- docs/scorecard.md | 4 +- docs/ui-parity.md | 4 +- 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4408766f1f4..2c58f067c90 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -370,7 +370,7 @@ pub struct ToolState { impl ToolState { /// How many CMYK picks are remembered. - const CMYK_PICKS: usize = 8; + pub const CMYK_PICKS: usize = 8; /// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1). pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1540dc1a587..6c7a45c4d1e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1008,7 +1008,7 @@ pub struct Preferences { /// Last selected tool, restored at launch. A tool-name string owned and validated by the /// shell; null in older files. Kept separate from tool options so neither overwrites the other. pub selected_tool: Value, - /// Foreground/background swatches for pixels and masks, restored by the shell at launch. + /// Foreground/background swatches for pixels and masks and exact CMYK picks, restored by the shell at launch. /// Null in older files, which retain the default black/white pixel colours. pub tool_colors: Value, /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index ced818bee2f..1aa3034bc9b 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -54,6 +54,7 @@ pub struct Runtime { pub(crate) tool_options: Option, pub(crate) selected_tool: Option, pub(crate) tool_colors: Option<[[[f32; 4]; 2]; 2]>, + pub(crate) tool_cmyk_picks: crate::tool_memory::CmykPicks, /// The tool owning the session brush and the other tools' brushes, as last remembered. pub(crate) tool_brushes: Option, } @@ -3527,6 +3528,76 @@ mod tests { assert_eq!(restored.session.tools.foreground, fg); } + #[test] + fn tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask() { + let (mut app, store) = app_with_store(); + let cyan = [1.0, 0.0, 0.0, 0.0]; + let magenta = [0.0, 1.0, 0.0, 0.0]; + let fg = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + let bg = photocraft_color::convert::cmyk_to_rgb(magenta).map(|c| c.clamp(0.0, 1.0)); + app.run( + "tools.setColors", + json!({ + "foreground": fg, "foregroundCmyk": cyan, "background": bg, "backgroundCmyk": magenta + }), + ) + .unwrap(); + app.run("tools.swapColors", json!({})).unwrap(); + app.session.tools.target_mask(true); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + let (mut restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(fg), Some(cyan)); + assert_eq!(restored.session.tools.picked_cmyk(bg), Some(magenta)); + for (target, expected) in [("foreground", magenta), ("background", cyan)] { + crate::color_picker_ui::open(&mut restored, target); + assert_eq!(restored.ui.dialogs.last().unwrap().fields["__cmyk"], json!(expected)); + } + restored.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + restored.run("image.mode.cmyk", json!({})).unwrap(); + for (command, expected) in [("edit.fillForeground", magenta), ("edit.fillBackground", cyan)] { + restored.run(command, json!({})).unwrap(); + let st = restored.session.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let pixel = surface.read_region(photocraft_geom::Rect::new(0, 0, 1, 1)); + assert!(pixel.iter().take(4).zip(expected).all(|(a, b)| (a - b).abs() < 0.01), "{command}: {pixel:?}"); + } + } + + #[test] + fn tool_colors_save_changed_cmyk_inks_even_when_rgba_is_unchanged() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + let rgb = [0.2, 0.4, 0.6]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": [1, 0, 0, 0]})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let rev = app.session.prefs.rev(); + let inks = [0.1, 0.2, 0.3, 0.4]; + app.run("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": inks})).unwrap(); + crate::tool_memory::persist(&mut app, &ctx); + assert!(app.session.prefs.rev() > rev); + tick(&mut app, &ctx); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.picked_cmyk(rgb), Some(inks)); + } + + #[test] + fn tool_colors_restore_only_bounded_valid_cmyk_picks() { + let limit = photocraft_engine::ToolState::CMYK_PICKS; + let mut picks: Vec = (0..limit + 3).map(|i| json!([[i as f32 / 16.0, 0.2, 0.3], [0.1, 0.2, 0.3, 0.4]])).collect(); + picks.extend([json!("bad"), json!([[0, 0, 0], [1, 0, 0]]), json!([[0, 0, 0], [2, 0, 0, 0]]), json!([[1e100, 0, 0], [1, 0, 0, 0]])]); + let (app, _) = app_with_saved(Some( + json!({ + "toolColors": {"foreground": [0.2, 0.4, 0.6, 1.0], "cmykPicks": picks} + }) + .to_string(), + )); + assert_eq!(app.session.tools.foreground, [0.2, 0.4, 0.6, 1.0]); + assert_eq!(app.session.tools.cmyk_picks.len(), limit); + assert_eq!(app.session.tools.picked_cmyk([0.0, 0.2, 0.3]), None); + assert_eq!(app.session.tools.picked_cmyk([(limit + 2) as f32 / 16.0, 0.2, 0.3]), Some([0.1, 0.2, 0.3, 0.4])); + } + #[test] fn tool_colors_flush_swaps_and_defaults_on_exit_without_another_frame() { let (mut app, store) = app_with_store(); diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 492f8534e7c..95066675c0c 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -21,6 +21,9 @@ use crate::state::{Tool, ToolOptions}; /// The tool whose brush the session brush is, and the other painting tools' brushes. pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); +/// The sRGB stand-ins and exact inks of recently picked CMYK colours, oldest first. +pub(crate) type CmykPicks = Vec<([f32; 3], [f32; 4])>; + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; @@ -89,15 +92,18 @@ fn colors(app: &PhotocraftApp) -> [[[f32; 4]; 2]; 2] { fn persist_colors(app: &mut PhotocraftApp, ctx: &egui::Context) { let now = colors(app); - if app.prefs_rt.tool_colors == Some(now) { + if app.prefs_rt.tool_colors == Some(now) && app.prefs_rt.tool_cmyk_picks == app.session.tools.cmyk_picks { return; } app.prefs_rt.tool_colors = Some(now); + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); + let cmyk_picks = &app.prefs_rt.tool_cmyk_picks; let [[foreground, background], [mask_foreground, mask_background]] = now; app.session.prefs.edit(|p| { p.tool_colors = json!({ "foreground": foreground, "background": background, "maskForeground": mask_foreground, "maskBackground": mask_background, + "cmykPicks": cmyk_picks, }); }); // Panels and shortcuts can change colours after prefs_ui::tick already ran this frame. @@ -116,10 +122,33 @@ fn restore_colors(app: &mut PhotocraftApp) { } } } + // Parse only a bounded number of valid entries, newest first, before mutating the session. + // `remember_cmyk` deduplicates entries and enforces the same bound as during a live session. + let picks: Option = saved.get("cmykPicks").and_then(Value::as_array).map(|entries| { + entries + .iter() + .rev() + .filter_map(|v| match serde_json::from_value::<([f32; 3], [f32; 4])>(v.clone()) { + Ok((rgb, inks)) if rgb.iter().all(|x| x.is_finite()) && inks.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)) => Some((rgb, inks)), + _ => { + log::warn!("Preferences: ignoring a malformed remembered CMYK colour"); + None + } + }) + .take(photocraft_engine::ToolState::CMYK_PICKS) + .collect() + }); let [pixels, mask] = pairs; let (current, other) = if app.session.tools.mask_targeted { (mask, pixels) } else { (pixels, mask) }; [app.session.tools.foreground, app.session.tools.background] = current; app.session.tools.other_colors = other; + if let Some(picks) = picks { + app.session.tools.cmyk_picks.clear(); + for (rgb, inks) in picks.into_iter().rev() { + app.session.tools.remember_cmyk(rgb, inks); + } + } + app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); app.prefs_rt.tool_colors = Some(pairs); } diff --git a/docs/scorecard.md b/docs/scorecard.md index 9fd35b7e257..1aae0d548ef 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 9 | 12 | 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) | 36 | 8 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 24 of 171 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 24 of 172 (target 0) | | [Type](#type) | 5 | 3 | 1 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -181,7 +181,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 24 of 171** (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: 24 of 172** (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.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.cache_levels`, `performance.gpu_adapter`, `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`, `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`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 59d05d1e40c..bebf3caa4f6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (foreground/background swatches and exact CMYK picks restored across launches; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -54,6 +54,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Press-and-hold or click-drag opens the flyout | missing | right-click only (#2301) | | Edit Toolbar customization | done | `edit.toolbar` | | Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | +| Foreground/background colours restored across launches, including separate mask colours, HDR/alpha and exact CMYK picks | done | `tool_memory.rs`; `prefs_ui::tests::tool_colors_survive_restart_before_the_first_frame`, `tool_colors_keep_pixel_and_mask_pairs_when_exiting_on_a_mask`, `tool_colors_restore_cmyk_inks_for_picker_and_fill_after_swapping_and_exiting_on_a_mask`, `tool_colors_preserve_finite_hdr_components` | ## Free Transform (⌘T) and transform handles @@ -262,3 +263,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 2026-10-22 | minor | Foreground/background colours restored before the first frame, with separate pixel/mask pairs, HDR/alpha, exact CMYK ink picks and a final normal-exit flush; malformed values ignored individually | From 432b1e3f43922fe21d6646857ed64cfc900617d9 Mon Sep 17 00:00:00 2001 From: GyoJoong Kum Date: Thu, 22 Oct 2026 08:12:30 +0000 Subject: [PATCH 023/384] fix(engine): validate tool preset colors before applying state --- crates/engine/src/presets/tests.rs | 41 ++++++++++++++++++++++++++++++ crates/engine/src/presets/tools.rs | 11 +++++--- crates/ui-egui/src/prefs_ui.rs | 20 +++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 432ef67ca95..081ff7aef6b 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -592,6 +592,47 @@ fn tool_presets_save_filter_select() { assert!(s.execute("tool.presets.new", json!({"name": "x"})).is_err()); } +#[test] +fn tool_presets_reject_overflowing_colors_before_applying_the_brush() { + for channel in 0..4 { + for overflow in [1e100, -1e100] { + let mut s = Session::new(); + s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap(); + let color = s.tools.foreground; + let brush = s.tools.brush.clone(); + let brush_rev = s.tools.brush_rev; + let mut values = [0.2, 0.4, 0.6, 0.8]; + values[channel] = overflow; + s.execute( + "tool.presets.new", + json!({ + "name": "Overflow", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values} + }), + ) + .unwrap(); + let err = s.execute("tool.presets.select", json!({"preset": "Overflow"})).unwrap_err(); + assert!(err.to_string().contains("finite 32-bit"), "{err}"); + assert_eq!(s.tools.foreground, color); + assert_eq!(s.tools.brush, brush); + assert_eq!(s.tools.brush_rev, brush_rev); + } + } +} + +#[test] +fn tool_presets_keep_finite_hdr_colors_and_leave_absent_colors_unchanged() { + let mut s = Session::new(); + for (options, expected) in [ + (json!({"foreground": [1.5, -0.1, 0.6, 0.8]}), [1.5, -0.1, 0.6, 0.8]), + (json!({"foreground": [0.2, 0.4, 0.6]}), [0.2, 0.4, 0.6, 1.0]), + (json!({}), [0.2, 0.4, 0.6, 1.0]), + ] { + let preset = s.execute("tool.presets.new", json!({"tool": "move", "options": options})).unwrap(); + s.execute("tool.presets.select", json!({"preset": preset["name"]})).unwrap(); + assert_eq!(s.tools.foreground, expected); + } +} + /// Crop presets are `crop` tool presets: saved, listed, persisted and deleted like the others. #[test] fn crop_presets_are_tool_presets_that_persist() { diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index 2746c1576ef..bb5f8b4477e 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -157,13 +157,16 @@ fn select(s: &mut Session, p: &Value) -> Result { .find(|t| t.name == name) .cloned() .ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; + // Validate before applying the brush too: an invalid colour must leave the preset unapplied. + let foreground = if t.options.get("foreground").and_then(Value::as_array).is_some_and(|a| a.len() >= 3) { + crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)? + } else { + s.tools.foreground + }; if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) { super::call(s, "tools.setBrush", json!({"brush": b}))?; } - if let Some(c) = t.options.get("foreground").and_then(Value::as_array).filter(|a| a.len() >= 3) { - let v: Vec = c.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - s.tools.foreground = [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]; - } + s.tools.foreground = foreground; Ok(json!({"name": t.name, "tool": t.tool, "options": t.options})) } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 1aa3034bc9b..d7156bdeaa1 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3518,6 +3518,26 @@ mod tests { assert_eq!(app.session.prefs.rev(), rev, "unchanged colours do not rewrite preferences"); } + #[test] + fn tool_colors_survive_restart_after_rejecting_an_overflowing_preset() { + let (mut app, store) = app_with_store(); + let color = [0.2, 0.4, 0.6, 0.8]; + app.run("tools.setColors", json!({"foreground": color})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + app.run( + "tool.presets.new", + json!({ + "name": "Overflow", "tool": "move", "options": {"foreground": [1e100, 0.4, 0.6, 1.0]} + }), + ) + .unwrap(); + assert!(app.run("tool.presets.select", json!({"preset": "Overflow"})).is_err()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["toolColors"]["foreground"], json!(color)); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, color); + } + #[test] fn tool_colors_preserve_finite_hdr_components() { let (mut app, store) = app_with_store(); From 52ed7ed27eb16c772628793e36789884119ae9a9 Mon Sep 17 00:00:00 2001 From: GyoJoong Kum Date: Thu, 22 Oct 2026 08:32:11 +0000 Subject: [PATCH 024/384] fix(engine): reject malformed tool preset color channels --- crates/engine/src/presets/tests.rs | 56 +++++++++++++++++++----------- crates/engine/src/presets/tools.rs | 13 ++++--- crates/ui-egui/src/prefs_ui.rs | 36 ++++++++++--------- 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 081ff7aef6b..c0cf7ed4878 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -593,30 +593,44 @@ fn tool_presets_save_filter_select() { } #[test] -fn tool_presets_reject_overflowing_colors_before_applying_the_brush() { +fn tool_presets_reject_invalid_colors_before_applying_the_brush() { + let mut invalid = vec![ + json!(null), + json!(true), + json!(7), + json!("red"), + json!({}), + json!([]), + json!([1]), + json!([1, 0]), + json!([1, 0, 0, 1, 0]), + json!([null, 0.4, 0.6]), + ]; for channel in 0..4 { - for overflow in [1e100, -1e100] { - let mut s = Session::new(); - s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap(); - let color = s.tools.foreground; - let brush = s.tools.brush.clone(); - let brush_rev = s.tools.brush_rev; - let mut values = [0.2, 0.4, 0.6, 0.8]; - values[channel] = overflow; - s.execute( - "tool.presets.new", - json!({ - "name": "Overflow", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values} - }), - ) - .unwrap(); - let err = s.execute("tool.presets.select", json!({"preset": "Overflow"})).unwrap_err(); - assert!(err.to_string().contains("finite 32-bit"), "{err}"); - assert_eq!(s.tools.foreground, color); - assert_eq!(s.tools.brush, brush); - assert_eq!(s.tools.brush_rev, brush_rev); + for bad_channel in [json!(1e100), json!(-1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let mut values = json!([0.2, 0.4, 0.6, 0.8]); + values[channel] = bad_channel; + invalid.push(values); } } + for values in invalid { + let mut s = Session::new(); + s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap(); + let color = s.tools.foreground; + let brush = s.tools.brush.clone(); + let brush_rev = s.tools.brush_rev; + s.execute( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values} + }), + ) + .unwrap(); + assert!(s.execute("tool.presets.select", json!({"preset": "Invalid"})).is_err(), "{values}"); + assert_eq!(s.tools.foreground, color, "{values}"); + assert_eq!(s.tools.brush, brush, "{values}"); + assert_eq!(s.tools.brush_rev, brush_rev, "{values}"); + } } #[test] diff --git a/crates/engine/src/presets/tools.rs b/crates/engine/src/presets/tools.rs index bb5f8b4477e..1ab90bd33b6 100644 --- a/crates/engine/src/presets/tools.rs +++ b/crates/engine/src/presets/tools.rs @@ -158,10 +158,15 @@ fn select(s: &mut Session, p: &Value) -> Result { .cloned() .ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?; // Validate before applying the brush too: an invalid colour must leave the preset unapplied. - let foreground = if t.options.get("foreground").and_then(Value::as_array).is_some_and(|a| a.len() >= 3) { - crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)? - } else { - s.tools.foreground + let foreground = match t.options.get("foreground") { + None => s.tools.foreground, + Some(value) => { + let valid = value.as_array().is_some_and(|channels| matches!(channels.len(), 3 | 4) && channels.iter().all(Value::is_number)); + if !valid { + return Err(bad(CMD, "`foreground` must be [r, g, b] or [r, g, b, a] numbers")); + } + crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)? + } }; if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) { super::call(s, "tools.setBrush", json!({"brush": b}))?; diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d7156bdeaa1..6f56206bf93 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3519,23 +3519,25 @@ mod tests { } #[test] - fn tool_colors_survive_restart_after_rejecting_an_overflowing_preset() { - let (mut app, store) = app_with_store(); - let color = [0.2, 0.4, 0.6, 0.8]; - app.run("tools.setColors", json!({"foreground": color})).unwrap(); - crate::tool_memory::save_on_exit(&mut app).unwrap(); - app.run( - "tool.presets.new", - json!({ - "name": "Overflow", "tool": "move", "options": {"foreground": [1e100, 0.4, 0.6, 1.0]} - }), - ) - .unwrap(); - assert!(app.run("tool.presets.select", json!({"preset": "Overflow"})).is_err()); - crate::tool_memory::save_on_exit(&mut app).unwrap(); - assert_eq!(stored(&store)["toolColors"]["foreground"], json!(color)); - let (restored, _) = app_with_saved(store.lock().unwrap().clone()); - assert_eq!(restored.session.tools.foreground, color); + fn tool_colors_survive_restart_after_rejecting_an_invalid_preset() { + for bad_channel in [json!(1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] { + let (mut app, store) = app_with_store(); + let color = [0.2, 0.4, 0.6, 0.8]; + app.run("tools.setColors", json!({"foreground": color})).unwrap(); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + app.run( + "tool.presets.new", + json!({ + "name": "Invalid", "tool": "move", "options": {"foreground": [bad_channel, 0.4, 0.6, 1.0]} + }), + ) + .unwrap(); + assert!(app.run("tool.presets.select", json!({"preset": "Invalid"})).is_err()); + crate::tool_memory::save_on_exit(&mut app).unwrap(); + assert_eq!(stored(&store)["toolColors"]["foreground"], json!(color)); + let (restored, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(restored.session.tools.foreground, color); + } } #[test] From ff2da1d1fa755e31490b1eab634af84d0b15aa38 Mon Sep 17 00:00:00 2001 From: bommerts <44821497+bommerts@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:50:54 -0400 Subject: [PATCH 025/384] Serialize the live-stroke GPU harness test --- crates/ui-egui/tests/it/live_stroke_canvas.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/tests/it/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs index ddda92ff64a..a6072003fb4 100644 --- a/crates/ui-egui/tests/it/live_stroke_canvas.rs +++ b/crates/ui-egui/tests/it/live_stroke_canvas.rs @@ -18,6 +18,7 @@ type Pixels = Vec<[u8; 4]>; #[test] fn raw_pointer_brush_and_mixer_pixels_appear_in_the_input_frame() { use photocraft_ui_egui::state::Tool; + let _gpu = photocraft_testkit::gpu_lock(); for flip in [false, true] { for tool in [Tool::Brush, Tool::MixerBrush] { let Some(mut h) = harness() else { return }; From beb50a43d371c2f7dbe813797b3a45abb70fcdbe Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 23 Oct 2026 00:06:24 +0200 Subject: [PATCH 026/384] Allowlist main's Color panel RGB sliders in panel-commands; regenerate scorecard Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- xtask/src/panel_commands.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 6e84b9847ee..5895456bcd9 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 21 of 176 (target 0) | | [Type](#type) | 5 | 4 | 0 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -239,7 +239,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 76d28a83e53..06777140af1 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -54,6 +54,13 @@ pub const ALLOWLIST: &[Allowed] = &[ func: "hsb_sliders", reason: "Color panel HSB sliders assign session.tools.foreground; dispatch tools.setColors", }, + // The Color panel's RGB sliders assign the foreground or background colour the same way. + // Fix: dispatch `tools.setColors`. + Allowed { + file: "crates/ui-egui/src/panels.rs", + func: "rgb_sliders", + reason: "Color panel RGB sliders assign session.tools.foreground/background; dispatch tools.setColors", + }, // The Color panel's colour field and hue strip (and their readout) assign the foreground or // background colour on every drag frame. Fix: dispatch `tools.setColors`. Allowed { From f26bd3e2570d31c3fed00b10cd99068e64dfe644 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 02:57:09 +0200 Subject: [PATCH 027/384] xtask: drop the panel-commands allowlist entries main fixed Main's Color panel rework removed hsb_sliders and rgb_sliders, so their allowlist entries were stale (the check fails on stale entries). One allowlisted function is left; the scorecard note and docs/scorecard.md follow. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- scorecard/automation.toml | 2 +- xtask/src/panel_commands.rs | 20 +++----------------- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 09f8c61ab9b..862720c5365 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 20 of 177 (target 0) | | [Type](#type) | 5 | 5 | 0 | | -| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 3 | 3 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 5 | 1 | | | [Open bugs](#open-bugs) | 31 | 1 | 0 | | @@ -244,7 +244,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 60ba6f87293..573d1e7ec2f 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -65,7 +65,7 @@ id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" status = "partial" issue = 219 -note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 2 allowlisted functions left (panels.rs hsb_sliders, color_field assign the foreground/background colour instead of tools.setColors)" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 06777140af1..5fd82abeb5e 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -46,23 +46,9 @@ pub struct Allowed { /// Existing click handlers that change the document directly. Shrink only: each entry is a /// follow-up (route it through an engine command), never a place to park new code. pub const ALLOWLIST: &[Allowed] = &[ - // Window › Color: the HSB sliders set the foreground colour by assignment, so the change - // never reaches the journal, action recording or the control channel's event log. - // Fix: dispatch `tools.setColors` (it already exists). - Allowed { - file: "crates/ui-egui/src/panels.rs", - func: "hsb_sliders", - reason: "Color panel HSB sliders assign session.tools.foreground; dispatch tools.setColors", - }, - // The Color panel's RGB sliders assign the foreground or background colour the same way. - // Fix: dispatch `tools.setColors`. - Allowed { - file: "crates/ui-egui/src/panels.rs", - func: "rgb_sliders", - reason: "Color panel RGB sliders assign session.tools.foreground/background; dispatch tools.setColors", - }, - // The Color panel's colour field and hue strip (and their readout) assign the foreground or - // background colour on every drag frame. Fix: dispatch `tools.setColors`. + // Window › Color: the colour field and hue strip (and their readout) assign the foreground or + // background colour on every drag frame, so the change never reaches the journal, action + // recording or the control channel's event log. Fix: dispatch `tools.setColors` (it already exists). Allowed { file: "crates/ui-egui/src/panels.rs", func: "color_field", From 239f5604a0a0c63a548444fbcef32cfc150cb73d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 01:31:26 +0200 Subject: [PATCH 028/384] Crop: Shift+H, Shift+P and Shift+I switch tools again (#9232) egui's consume_key ignores an extra Shift, so the Crop tool's plain H/P/I checks also took Shift+H/P/I before the tool-switch loop saw them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_shield.rs | 24 ++++++++++++++++++++++-- crates/ui-egui/src/crop_size.rs | 19 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/crop_shield.rs b/crates/ui-egui/src/crop_shield.rs index aef70e5afbd..29588199fb4 100644 --- a/crates/ui-egui/src/crop_shield.rs +++ b/crates/ui-egui/src/crop_shield.rs @@ -122,18 +122,19 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { /// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic /// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. +/// ⇧H and ⇧P are always the tool keys (`consume_key` would also take them: it ignores ⇧). pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::H)) { + if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; if !s.classic_mode { s.show_cropped_area = !s.show_cropped_area; } return true; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::P)) { + if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::P)) { let s = &mut app.ui.tool_options.crop_shield; s.classic_mode = !s.classic_mode; // The view follows at once, about its own centre: default mode turns it with the frame, @@ -342,6 +343,25 @@ mod tests { assert!(app.ui.tool_options.crop_shield.show_cropped_area); } + /// ⇧H and ⇧P are the Hand and Pen tool keys even with a crop box: egui's `consume_key` ignores + /// an extra ⇧, so the plain-key checks must refuse it. + #[test] + fn shift_h_and_shift_p_switch_tools_instead_of_crop_options() { + for (key, tool) in [(Key::H, Tool::Hand), (Key::P, Tool::Pen)] { + let mut app = crop_app(); + assert!(app.session.prefs().tools.use_shift_key_for_tool_switch); + let before = app.ui.tool_options.crop_shield.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.modifiers = Modifiers::SHIFT; + i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, tool, "{key:?}"); + assert_eq!(app.ui.tool_options.crop_shield, before, "{key:?} leaves Show Cropped Area and Classic Mode alone"); + } + } + #[test] fn the_color_picker_sets_the_custom_colour() { let mut app = crop_app(); diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 71fc57f4952..df1fcc19fe7 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -260,12 +260,12 @@ pub fn chosen(app: &mut PhotocraftApp) { } /// I with the Crop tool (no text field focused, which the caller checks): Front Image. Returns -/// true when the key was used; otherwise I stays the Eyedropper tool key. +/// true when the key was used; otherwise I (and always ⇧I) stays the Eyedropper tool key. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() || app.session.active().is_none() { return false; } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::I)) { + if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, egui::Key::I)) { front_image(app); crate::crop_ui::apply_ratio(app); return true; @@ -497,6 +497,21 @@ mod tests { assert!(!keys(&mut app2, &ctx)); } + /// ⇧I is the Eyedropper group's key even with the Crop tool, not Front Image. + #[test] + fn shift_i_with_the_crop_tool_switches_to_the_eyedropper() { + let mut app = app(SampleType::U8); + let ratio = app.ui.tool_options.crop_ratio.clone(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.modifiers = Modifiers::SHIFT; + i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + }); + crate::shortcuts::handle(&mut app, &ctx); + assert_eq!(app.ui.tool, Tool::Eyedropper); + assert_eq!(app.ui.tool_options.crop_ratio, ratio); + } + /// Committing a W x H x Resolution crop resamples the frame's area to exactly W x H px and sets /// the resolution, in one undo step, at 8 and 16 bits. #[test] From 5988587b22cb3695cbcc8a050e178c881ed69f17 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 01:49:50 +0200 Subject: [PATCH 029/384] Crop: check each key event's own Shift, so control-channel keys are covered (#9232) Synthetic keys from the control channel carry their modifiers on the event only; the frame's modifier state doesn't see them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/crop_shield.rs | 17 +++++++++++++---- crates/ui-egui/src/crop_size.rs | 7 ++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/crop_shield.rs b/crates/ui-egui/src/crop_shield.rs index 29588199fb4..2acdd18c636 100644 --- a/crates/ui-egui/src/crop_shield.rs +++ b/crates/ui-egui/src/crop_shield.rs @@ -122,19 +122,19 @@ pub fn hides_outside(app: &PhotocraftApp) -> bool { /// H with the Crop tool showing a box and no drag in progress toggles Show Cropped Area (in /// Classic Mode, where the option is greyed out, H is taken and does nothing), and P Use Classic /// Mode. Returns true when a key was used; otherwise H stays the Hand tool key and P the Pen's. -/// ⇧H and ⇧P are always the tool keys (`consume_key` would also take them: it ignores ⇧). +/// ⇧H and ⇧P are always the tool keys. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.ui.crop_rect.is_none() || app.crop.drag.is_some() { return false; } - if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::H)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::H)) { let s = &mut app.ui.tool_options.crop_shield; if !s.classic_mode { s.show_cropped_area = !s.show_cropped_area; } return true; } - if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, Key::P)) { + if ctx.input_mut(|i| consume_unshifted(i, Key::P)) { let s = &mut app.ui.tool_options.crop_shield; s.classic_mode = !s.classic_mode; // The view follows at once, about its own centre: default mode turns it with the frame, @@ -145,6 +145,15 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } +/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧: egui ignores an extra ⇧ +/// there, but ⇧+letter is the tool key. Reads each event's own modifiers, which synthetic +/// (control channel) keys carry without changing the frame's. +pub(crate) fn consume_unshifted(i: &mut egui::InputState, key: Key) -> bool { + let before = i.events.len(); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && modifiers.matches_logically(Modifiers::NONE))); + i.events.len() != before +} + /// The options bar's gear button and its menu. Returns true when Custom… asks for the Color /// Picker ([`pick_custom_color`], which needs the app). pub fn options_button(s: &mut CropShield, ui: &mut egui::Ui) -> bool { @@ -353,7 +362,7 @@ mod tests { let before = app.ui.tool_options.crop_shield.clone(); let ctx = egui::Context::default(); ctx.input_mut(|i| { - i.modifiers = Modifiers::SHIFT; + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); }); crate::shortcuts::handle(&mut app, &ctx); diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index df1fcc19fe7..90662997c95 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -15,7 +15,7 @@ //! with the Crop tool, [`keys`]) fills them with the active document's size in px and its //! resolution; Clear empties them; ⇄ and X swap W and H. -use egui::{Modifiers, Stroke, StrokeKind, vec2}; +use egui::{Stroke, StrokeKind, vec2}; use photocraft_engine::prefs::Unit; use serde_json::{Value, json}; @@ -265,7 +265,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if app.ui.tool != Tool::Crop || app.crop.drag.is_some() || app.session.active().is_none() { return false; } - if ctx.input_mut(|i| !i.modifiers.shift && i.consume_key(Modifiers::NONE, egui::Key::I)) { + if ctx.input_mut(|i| crate::crop_shield::consume_unshifted(i, egui::Key::I)) { front_image(app); crate::crop_ui::apply_ratio(app); return true; @@ -322,6 +322,7 @@ pub fn fields(o: &mut ToolOptions, ui: &mut egui::Ui) { #[cfg(test)] mod tests { use super::*; + use egui::Modifiers; use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; #[test] @@ -504,7 +505,7 @@ mod tests { let ratio = app.ui.tool_options.crop_ratio.clone(); let ctx = egui::Context::default(); ctx.input_mut(|i| { - i.modifiers = Modifiers::SHIFT; + // Only the event holds ⇧, as with a control-channel `ui.key` (the frame's state lags). i.events.push(egui::Event::Key { key: egui::Key::I, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); }); crate::shortcuts::handle(&mut app, &ctx); From 79f9ff4cdea688eb5b39696c2a5db5cb4e4f1a1d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:28:49 +0200 Subject: [PATCH 030/384] Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scale_effects scaled Pattern Overlay's and a pattern Stroke's scale but left Bevel & Emboss › Texture at its old size, so a texture aligned with the overlay drifted after Scale Effects, Image Size or Crop W x H. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_menu_cmds/tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index b0cceb72a83..4a388c29e1b 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,26 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() { + let mut s = session(8, "rgb"); + paint(&mut s, disc); + s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap(); + s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap(); + s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap(); + let fx = &active(&s).effects.items; + let overlay = fx.iter().find_map(|e| match e { + Effect::PatternOverlay { scale, .. } => Some(*scale), + _ => None, + }); + let texture = fx.iter().find_map(|e| match e { + Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale), + _ => None, + }); + // Both patterns tile at half size, so a texture aligned with the overlay stays aligned. + assert_eq!((overlay, texture), (Some(0.5), Some(0.5))); +} + #[test] fn global_light_rejects_numbers_beyond_f32() { let mut s = session(8, "rgb"); From 42ec692355a1b842c94dfd9c3104c7250d00d3db Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:34:00 +0200 Subject: [PATCH 031/384] Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254) Multiply BevelTexture::scale by the effects factor in scale_effects, as Pattern Overlay's and a pattern Stroke's scale already are. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc7..32eadaed411 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -150,6 +150,9 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { Effect::BevelEmboss(b) => { b.size *= k; b.soften *= k; + if let Some(t) = &mut b.texture { + t.scale *= k; + } } Effect::PatternOverlay { scale, .. } => *scale *= k, Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} From df77c5065720ce5485974895129a4b9b2f490bd9 Mon Sep 17 00:00:00 2001 From: FartmanAndBurps <345790579+FartmanAndBurps@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:28:15 +0000 Subject: [PATCH 032/384] Preview Work Path dragging without document mutations Show fractional drag feedback and commit one undoable move on release. Cancel stale or interrupted gestures safely. --- crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/shortcuts.rs | 8 + crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/vector_ui.rs | 25 +- crates/ui-egui/src/work_path_drag.rs | 157 +++++++++++++ crates/ui-egui/src/work_path_drag_tests.rs | 256 +++++++++++++++++++++ 7 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/work_path_drag.rs create mode 100644 crates/ui-egui/src/work_path_drag_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a9a3413165..36e63683d53 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3473,6 +3473,10 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); + crate::work_path_drag::reconcile(app, ctx.input(|i| i.focused)); + if crate::work_path_drag::preview(app).is_some() { + ctx.request_repaint(); + } // The camera as the app holds it before this frame's input. The canvas works on its own copy // (`view`) and writes it back at the end; a tool that moves the camera itself meanwhile (the // Crop tool's default mode pans and turns the view with the image, `crop_mode`) must not be @@ -5808,6 +5812,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: } else if tool == Tool::CurvaturePen { crate::vector_ui::curvature_move(app, x, y); } + let mut preview_at = [x, y]; let zoom = app.point_zoom(); if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) { d.track(mods); @@ -5820,6 +5825,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: // ⇧: a selection-tool drag of the selection locks to multiples of 45° (#6231). The Move // tool's own drags were already locked by `move_mods::filter_event`. let [x, y] = crate::move_mods::selection_drag_to(d, [x, y], mods.shift && tool != Tool::Move); + preview_at = [x, y]; let moved = d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25); // A held pen repeats its point now and then, so the stroke's time keeps advancing. let held = app.stylus.last_point_ms().is_some_and(|t| app.stylus.clock_ms - t >= HOLD_REPEAT_MS) @@ -5829,6 +5835,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: app.stylus.record_point(); } } + crate::work_path_drag::motion(app, preview_at); } /// Tool state machine. Shared by mouse input and automation. @@ -6129,6 +6136,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { let _ = app.run("layer.select", json!({"layer": id.0})); } + crate::work_path_drag::press(app, [x, y]); } Tool::Pen => { if !(mods.alt && crate::vector_ui::pen_grab_handle(app, x, y)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4db3dc2a344..07e9d52b041 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -257,6 +257,7 @@ pub mod wheel_nav; pub mod wide_angle_ui; pub mod widgets; pub mod work_area; +pub mod work_path_drag; pub mod workspace_ui; pub mod zoom_hold; pub mod zoom_levels; @@ -1292,6 +1293,9 @@ impl PhotocraftApp { } self.sync_mask_targets(); } + if result.is_ok() { + work_path_drag::after_command(self, id); + } result } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 023bbb4d795..7bcef086f2a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -667,6 +667,14 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + if crate::work_path_drag::owns_drag(app) + && app.drag.as_ref().is_some_and(|d| d.tool == crate::state::Tool::PathSelection) + && ctx.input_mut(|i| i.consume_key(i.modifiers, Key::Escape)) + { + crate::work_path_drag::cancel(app); + ctx.request_repaint(); + return; + } // Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop). if crate::lasso_ui::active(app) { let mods = ctx.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b3b9d7b05e1..effb7764993 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1474,6 +1474,9 @@ pub struct UiState { /// Selected row in the Paths panel ("work" or a saved path name). #[serde(default)] pub selected_path: Option, + /// Whole-Work-Path drag preview is never persisted into preferences or documents. + #[serde(skip)] + pub work_path_drag: crate::work_path_drag::State, /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, @@ -1677,6 +1680,7 @@ impl Default for UiState { direct_selection: Default::default(), corner_radius: None, selected_path: None, + work_path_drag: Default::default(), panels: Panels::default(), views: Vec::new(), dialogs: Vec::new(), diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 81426ccb5e8..b5701313d4d 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1028,6 +1028,13 @@ pub fn pick_shape_at(doc: &Document, at: [f64; 2], tol: f64) -> Option, + selected_path: Option, + path: Path, + start: [f64; 2], + end: [f64; 2], +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct State { + gesture: Option, + // A cancelled/invalidated gesture must consume its late release, never retarget it. + owns_release: bool, +} + +fn work_target(app: &PhotocraftApp) -> bool { + let Some(st) = app.session.active() else { return false }; + // Preserve upstream layer/mask priority. Named saved paths retain their legacy route; + // adding saved-path editing is outside this Work-Path-only change. + if app.ui.selected_path.as_deref().is_some_and(|name| name != "work") { + return false; + } + if let Some(layer) = st.active_layer.and_then(|id| st.doc.layer(id)) { + if matches!(layer.content, LayerContent::Shape(_)) { + return false; + } + let mask = app.ui.vector_mask_target || st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::VectorMask; + if mask && layer.vector_mask.is_some() { + return false; + } + } + st.doc.work_path.is_some() +} + +fn valid(app: &PhotocraftApp, gesture: &Gesture) -> bool { + app.ui.tool == Tool::PathSelection + && app.ui.selected_path == gesture.selected_path + && work_target(app) + && app.session.active().is_some_and(|st| { + st.doc.id.0 == gesture.document + && st.revision == gesture.revision + && st.active_layer == gesture.layer + && st.doc.work_path.as_ref() == Some(&gesture.path) + }) +} + +/// Same finite fractional offset for preview and commit, retaining the old click dead zone. +fn displacement(start: [f64; 2], end: [f64; 2]) -> Option<[f64; 2]> { + let delta = [end[0] - start[0], end[1] - start[1]]; + if !start.iter().chain(end.iter()).all(|v| v.is_finite()) || !delta.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.0) { + return None; + } + Some(if delta[0].abs() + delta[1].abs() < 0.5 { [0.0, 0.0] } else { delta }) +} + +/// Called after upstream's existing shape picking, without changing the selected target. +pub fn press(app: &mut PhotocraftApp, at: [f64; 2]) { + app.ui.work_path_drag = State::default(); + if !app.ui.view.shows(app.ui.view.show.target_path) || !work_target(app) || displacement(at, at).is_none() { + return; + } + let Some(st) = app.session.active() else { return }; + let Some(path) = st.doc.work_path.clone() else { return }; + app.ui.work_path_drag = State { + gesture: Some(Gesture { + document: st.doc.id.0, + revision: st.revision, + layer: st.active_layer, + selected_path: app.ui.selected_path.clone(), + path, + start: at, + end: at, + }), + owns_release: true, + }; +} + +/// Canvas supplies document coordinates after its existing constraints, before sample gating. +pub fn motion(app: &mut PhotocraftApp, at: [f64; 2]) { + let Some(gesture) = app.ui.work_path_drag.gesture.as_ref() else { return }; + if !valid(app, gesture) || displacement(gesture.start, at).is_none() { + cancel(app); + return; + } + if let Some(gesture) = &mut app.ui.work_path_drag.gesture { + gesture.end = at; + } +} + +/// Invalidate immediately after a command, even if a later command restores the target. +pub fn after_command(app: &mut PhotocraftApp, id: &str) { + if let Some(gesture) = &app.ui.work_path_drag.gesture + && (!valid(app, gesture) || id == "layer.select" || id.starts_with("channel.target")) + { + cancel(app); + } +} + +/// Renderer consumes an offset, not a cloned or serialized path. +pub fn preview(app: &PhotocraftApp) -> Option<[f64; 2]> { + let gesture = app.ui.work_path_drag.gesture.as_ref()?; + let drag = app.drag.as_ref()?; + if !valid(app, gesture) || drag.tool != Tool::PathSelection || drag.start != gesture.start { + return None; + } + displacement(gesture.start, gesture.end) +} + +pub fn owns_drag(app: &PhotocraftApp) -> bool { + app.ui.work_path_drag.owns_release +} + +pub fn cancel(app: &mut PhotocraftApp) { + app.ui.work_path_drag.gesture = None; + if app.drag.as_ref().is_some_and(|d| d.tool == Tool::PathSelection) { + app.drag = None; + } +} + +pub fn reconcile(app: &mut PhotocraftApp, focused: bool) { + let Some(gesture) = app.ui.work_path_drag.gesture.as_ref() else { return }; + let lost = app.drag.as_ref().is_none_or(|d| d.tool != Tool::PathSelection || d.start != gesture.start); + if !focused || lost || !valid(app, gesture) { + cancel(app); + } +} + +/// None delegates non-Work-Path gestures to upstream. Owned late releases never delegate. +pub fn finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> Option> { + if !std::mem::take(&mut app.ui.work_path_drag.owns_release) { + return None; + } + let Some(gesture) = app.ui.work_path_drag.gesture.take() else { return Some(Err("Work Path drag was cancelled.".into())) }; + if start != gesture.start || !valid(app, &gesture) { + return Some(Err("Work Path drag target changed; no path was moved.".into())); + } + let Some([dx, dy]) = displacement(start, end) else { return Some(Err("Invalid Work Path displacement.".into())) }; + if dx == 0.0 && dy == 0.0 { + return Some(Ok(())); + } + let moved = gesture.path.transform(&Affine::translate(dx, dy)); + Some(app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&moved)})).map(|_| ())) +} + +#[cfg(test)] +#[path = "work_path_drag_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/work_path_drag_tests.rs b/crates/ui-egui/src/work_path_drag_tests.rs new file mode 100644 index 00000000000..5bfbe482898 --- /dev/null +++ b/crates/ui-egui/src/work_path_drag_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use crate::canvas::{ToolEvent, tool_event}; +use photocraft_doc::vector::Subpath; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200, "background": "white"})).unwrap(); + let mut path = photocraft_vector::shapes::rect(10.0, 20.0, 50.0, 50.0); + path.subpaths.extend(photocraft_vector::shapes::ellipse(100.0, 20.0, 80.0, 60.0).subpaths); + path.subpaths.push(Subpath::polyline(&[(220.0, 40.0), (300.0, 60.0)])); + app.run("path.set", json!({"path": photocraft_engine::vector_cmds::path_json(&path)})).unwrap(); + app.ui.tool = Tool::PathSelection; + app.ui.view.extras = true; + app.ui.view.show.target_path = true; + app.sync_views(); + app +} +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().unwrap() +} +fn event(app: &mut PhotocraftApp, event: ToolEvent) { + tool_event(app, event, egui::Modifiers::NONE); +} +fn down(app: &mut PhotocraftApp, at: [f64; 2]) { + event(app, ToolEvent::Down { x: at[0], y: at[1], pressure: 1.0 }); +} +fn moving(app: &mut PhotocraftApp, at: [f64; 2]) { + event(app, ToolEvent::Move { x: at[0], y: at[1], pressure: 1.0 }); +} +fn up(app: &mut PhotocraftApp, at: [f64; 2]) { + event(app, ToolEvent::Up { x: at[0], y: at[1] }); +} + +#[test] +fn whole_path_previews_before_release_without_mutation_then_commits_once() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + let revision = app.session.active().unwrap().revision; + let pixels = photocraft_compose::flatten(&app.session.active().unwrap().doc); + down(&mut app, [10.0, 20.0]); + for delta in [[1.125, 2.25], [15.375, -3.125], [-5.25, 8.125]] { + moving(&mut app, [10.0 + delta[0], 20.0 + delta[1]]); + assert_eq!(preview(&app), Some(delta)); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert_eq!(app.session.active().unwrap().revision, revision); + } + up(&mut app, [4.75, 28.125]); + let after = before.transform(&Affine::translate(-5.25, 8.125)); + assert_eq!(work(&app), after); + assert!(preview(&app).is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); + assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc), pixels); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(work(&app), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(work(&app), after); + down(&mut app, [4.75, 28.125]); + moving(&mut app, [8.0, 30.0]); + let layer = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.select", json!({"layer": layer.0})).unwrap(); + assert!(preview(&app).is_none()); + assert!(finish(&mut app, [4.75, 28.125], [8.0, 30.0]).unwrap().is_err()); + assert_eq!(work(&app), after); +} + +#[test] +fn endpoint_updates_below_generic_stroke_sampling_threshold() { + let mut app = app(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [11.0, 20.0]); + let samples = app.drag.as_ref().unwrap().points.len(); + moving(&mut app, [11.0625, 20.03125]); + assert_eq!(app.drag.as_ref().unwrap().points.len(), samples); + assert_eq!(preview(&app), Some([1.0625, 0.03125])); + up(&mut app, [11.0625, 20.03125]); + assert_eq!(work(&app).subpaths[0].knots[0].anchor, photocraft_geom::Point::new(11.0625, 20.03125)); +} + +#[test] +fn click_dead_zone_and_invalid_displacement_do_not_create_history() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [10.125, 20.125]); + assert_eq!(preview(&app), Some([0.0, 0.0])); + up(&mut app, [10.125, 20.125]); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(displacement([0.0, 0.0], [f64::NAN, 0.0]).is_none()); + assert!(displacement([0.0, 0.0], [f64::INFINITY, 0.0]).is_none()); + assert!(displacement([0.0, 0.0], [1_000_001.0, 0.0]).is_none()); +} + +#[test] +fn escape_uses_real_shortcut_and_rejects_late_release() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [20.125, 25.5]); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut input = egui::RawInput::default(); + input.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + ctx.run_ui(input, |ui| crate::shortcuts::handle(&mut app, ui.ctx())).textures_delta.clear(); + assert!(app.drag.is_none()); + assert!(preview(&app).is_none()); + assert!(finish(&mut app, [10.0, 20.0], [20.125, 25.5]).unwrap().is_err()); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +#[test] +fn stale_document_revision_geometry_target_and_tool_never_fall_back() { + for reason in ["revision", "geometry", "document", "target", "tool", "lost", "focus"] { + let mut app = app(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [20.0, 25.0]); + match reason { + "revision" => app.session.active_mut().unwrap().revision += 1, + "geometry" => { + std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).work_path = Some(photocraft_vector::shapes::rect(1.0, 1.0, 10.0, 10.0)) + } + "document" => { + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + } + "target" => app.ui.selected_path = Some("Saved".into()), + "tool" => app.ui.tool = Tool::DirectSelection, + "lost" => app.drag = None, + "focus" => reconcile(&mut app, false), + _ => assert!(matches!(reason, "revision" | "geometry" | "document" | "target" | "tool" | "lost" | "focus")), + } + reconcile(&mut app, true); + let before = app.session.active().unwrap().doc.work_path.clone(); + let history = app.session.active().unwrap().history.past_len(); + assert!(preview(&app).is_none(), "{reason}"); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 25.0]).unwrap().is_err(), "{reason}"); + assert_eq!(app.session.active().unwrap().doc.work_path, before, "{reason}"); + assert_eq!(app.session.active().unwrap().history.past_len(), history, "{reason}"); + } +} + +#[test] +fn named_paths_shape_layers_and_masks_retain_their_existing_route() { + let mut app = app(); + app.run("path.set", json!({"name": "Saved", "path": {"subpaths": [{"knots": [[5,5],[15,15]]}]}})).unwrap(); + app.ui.selected_path = Some("Saved".into()); + let before = app.session.active().unwrap().doc.clone(); + press(&mut app, [10.0, 20.0]); + assert!(preview(&app).is_none()); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 20.0]).is_none()); + assert_eq!(app.ui.selected_path.as_deref(), Some("Saved")); + assert_eq!(app.session.active().unwrap().doc, before); + app.ui.selected_path = Some("work".into()); + app.run("layer.vectorMask.revealAll", json!({})).unwrap(); + press(&mut app, [10.0, 20.0]); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 20.0]).is_none()); + app.ui.vector_mask_target = false; + app.run("shape.create", json!({"kind":"rect", "rect":[10,20,40,40]})).unwrap(); + press(&mut app, [10.0, 20.0]); + assert!(finish(&mut app, [10.0, 20.0], [20.0, 20.0]).is_none()); +} + +fn overlay(app: &PhotocraftApp, xf: crate::canvas::ViewXform) -> Vec { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + crate::vector_ui::draw_overlay(app, &painter, &xf, &app.session.active().unwrap().doc); + }); + output.textures_delta.clear(); + output.shapes.into_iter().map(|s| s.shape).collect() +} + +#[test] +fn actual_overlay_moves_all_outlines_and_anchors_once() { + let mut app = app(); + down(&mut app, [10.0, 20.0]); + let xf = crate::canvas::ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), + zoom: 1.0, + center: [0.0, 0.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let before = overlay(&app, xf); + moving(&mut app, [21.25, 24.5]); + let during = overlay(&app, xf); + let paths = |shapes: &[egui::Shape]| { + shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Path(p) => Some(p.clone()), + _ => None, + }) + .collect::>() + }; + let a = paths(&before); + let b = paths(&during); + assert_eq!(a.len(), 3); + assert_eq!(b.len(), 3, "no duplicate original outlines"); + for (a, b) in a.iter().zip(&b) { + assert_eq!(a.points.len(), b.points.len()); + for (a, b) in a.points.iter().zip(&b.points) { + assert!((*b - *a - egui::vec2(11.25, 4.5)).length() < 0.001); + } + } + let anchors = |shapes: &[egui::Shape]| { + shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) => Some(r.rect.center()), + _ => None, + }) + .collect::>() + }; + let a = anchors(&before); + let b = anchors(&during); + assert_eq!(a.len(), 20); + assert_eq!(b.len(), 20); + for (a, b) in a.iter().zip(&b) { + assert!((*b - *a - egui::vec2(11.25, 4.5)).length() < 0.001); + } +} + +#[test] +fn view_mapping_fractional_offsets_and_transient_state_are_preserved() { + for (zoom, rotation, flip, aspect) in [(0.25, 0.0, false, 1.0), (2.0, 30.0, false, 1.0), (8.0, 90.0, true, 1.5)] { + let mut app = app(); + let xf = crate::canvas::ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(800.0, 600.0)), + zoom, + center: [80.0, 60.0], + flip, + rotation, + aspect, + }; + let start = xf.to_doc(xf.to_screen(10.0, 20.0)); + let end = xf.to_doc(xf.to_screen(21.25, 24.5)); + app.ui.extras.snap = true; + app.ui.view.snap_to.grid = true; + let before = work(&app); + down(&mut app, start); + moving(&mut app, end); + let delta = preview(&app).unwrap(); + assert!((delta[0] - (end[0] - start[0])).abs() < 1e-8); + assert!((delta[1] - (end[1] - start[1])).abs() < 1e-8); + assert!(serde_json::to_value(&app.ui).unwrap().get("work_path_drag").is_none()); + up(&mut app, end); + assert_eq!(work(&app), before.transform(&Affine::translate(delta[0], delta[1]))); + } +} From 46ac4fc8f3af8579ee7dbfe6d323bacbcbbfd650 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:28:59 +0100 Subject: [PATCH 033/384] Crop: refuse an invalid explicit rectangle instead of cropping to the selection (#9279) image.crop built an explicit rectangle only when x, y, width and height were all integers with width and height above 0; anything else fell back to the selection's bounds, so width 0, a negative or string size, or only some of the four keys cropped to the selection and reported success. A rectangle that is passed (any of the four keys non-null) but incomplete or empty is now a bad-params error and the document is unchanged; {} still crops to the selection. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc7..24f39f2184d 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -567,6 +567,7 @@ fn crop(s: &mut Session, p: &Value) -> Result { }; // Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to // the numbers passed; reject them (see `commands::int_i32`). + let given = ["x", "y", "width", "height"].into_iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())); let explicit = match ( crate::commands::int_i32("image.crop", p, "x")?, crate::commands::int_i32("image.crop", p, "y")?, @@ -587,6 +588,14 @@ fn crop(s: &mut Session, p: &Value) -> Result { }; Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?)) } + // A rectangle that is passed but incomplete or empty is refused: falling back to the + // selection would crop somewhere the caller didn't ask for (#9279). + _ if given => { + return Err(bad( + "image.crop", + "`x`, `y`, `width` and `height` go together: pass all four as numbers of pixels, with `width` and `height` rounding to at least 1", + )); + } _ => None, }; let target = crop_target(p)?; @@ -975,7 +984,7 @@ pub fn specs() -> Vec { "image.crop", "Crop", ["Image"], - r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, + r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (x/y/width/height, all four or none: without them the selection's bounds are cropped to; targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; contentAware: the Crop tool's Fill › Content-Aware Fill, the area a rotated or extending crop uncovers filled from its surroundings on the Background, else the active pixel layer)"##, has_doc, crop ), @@ -1423,6 +1432,48 @@ mod tests { assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]); } + /// A rectangle that is passed but invalid is refused, not replaced by the selection (#9279). + #[test] + fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { + let bad = [ + json!({"x": 10, "y": 10, "width": 0, "height": 50}), + json!({"x": 10, "y": 10, "width": 20, "height": 0}), + json!({"x": 10, "y": 10, "width": -50, "height": 50}), + json!({"x": 10, "y": 10, "width": "100", "height": 50}), + json!({"x": 10, "y": 10, "width": true, "height": 50}), + json!({"width": 20, "height": 10}), + json!({"x": 1, "y": 1}), + json!({"x": 1, "y": null, "width": 20, "height": 10}), + json!({"height": 10}), + ]; + for p in bad { + for selection in [true, false] { + let mut s = session(); + if selection { + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + } + let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len()); + let err = s.execute("image.crop", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p} (selection: {selection}): {err:?}"); + assert_eq!(doc(&s).size, size, "{p} (selection: {selection})"); + assert_eq!(doc(&s).selection.is_some(), selection, "{p}: the selection stays"); + assert_eq!(s.active().unwrap().history.past_len(), past, "{p} (selection: {selection})"); + } + } + // No rectangle at all (or every side null) still crops to the selection. + for p in [json!({}), json!({"x": null, "y": null, "width": null, "height": null})] { + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", p.clone()).unwrap(); + assert_eq!(doc(&s).size, Size::new(20, 10), "{p}"); + } + // Whole-number floats, as the Crop tool sends them, are a valid rectangle. + let mut s = session(); + s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); + s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap(); + assert_eq!(doc(&s).size, Size::new(30, 15)); + } + #[test] fn crop_extension_fills_the_background_with_the_background_colour() { let mut shrink = Session::new(); From 91e18fa45a5d70f7760ffe9287448e3071b50682 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 02:19:26 -0200 Subject: [PATCH 034/384] paint: 13 more painting tools wrap across the canvas edges in Pattern Preview Clone Stamp already wraps in Pattern Preview (#4997). Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Mixer Brush, Color Replacement, Background Eraser, Pattern Stamp, History Brush, Healing Brush and Spot Healing now take "wrap": true as well, and the canvas sends it while Pattern Preview is on (#8883). The sequential-dab tools run their dabs on WorkTiles, moved into photocraft_paint::retouch and given a wrap tile: each canvas pixel is kept once and read and written wherever it repeats, so a live stroke and its commit still give the same pixels. The composite tools go through #4997's periodic::Composite via one periodic::stroke helper. Spot Healing centres its sampling window on the stroke, at most one canvas across. With wrap off, every command takes the path it took before. --- crates/engine/src/brush_cmds.rs | 20 +- crates/engine/src/eraser_cmds.rs | 12 +- crates/engine/src/retouch_cmds.rs | 364 ++++++++++-------- .../engine/src/retouch_cmds/pattern_stamp.rs | 14 +- crates/engine/src/retouch_cmds/periodic.rs | 25 ++ crates/engine/src/retouch_cmds/tests.rs | 289 ++++++++++++++ crates/paint/src/bg_erase.rs | 19 +- crates/paint/src/lib.rs | 4 +- crates/paint/src/mixer.rs | 31 +- crates/paint/src/replace.rs | 42 +- crates/paint/src/retouch.rs | 264 +++++++++++++ crates/paint/src/wrap.rs | 166 ++++++++ crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/eraser_ui.rs | 6 +- crates/ui-egui/src/retouch_ui.rs | 56 ++- crates/ui-egui/src/stroke_timing_tests.rs | 54 +++ 16 files changed, 1179 insertions(+), 195 deletions(-) create mode 100644 crates/paint/src/wrap.rs diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index feb238cf402..bd82e4f8088 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement_wrapped, color_replacement_effect}; use photocraft_paint::tile::PREVIEW_SIDE; use photocraft_paint::{ BrushPreset, BrushSettings, GrayTile, Pattern, PresetCaptures, StoredTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke, @@ -667,10 +667,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { let sel = doc.selection.clone(); + // View › Pattern Preview: the stroke paints and picks up where it lies on the canvas. + let wrap = flag(p, "wrap", false).then(|| doc.bounds()).filter(|t| !t.is_empty()); let sample = if sample_all { let ds = photocraft_paint::dabs(&stroke); let ctx = photocraft_paint::BrushContext::new(&stroke.brush); let area = ds.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))); + let area = wrap.map_or(area, |t| photocraft_paint::wrap::fold_area(area, t)); let buf = photocraft_compose::render(doc, area); let mut surf = Surface::new(photocraft_color::PixelFormat::RGBA32F); let flat: Vec = buf.px.iter().flatten().copied().collect(); @@ -682,7 +685,7 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { None }; let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) + Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock, wrap)) })?; state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; @@ -752,8 +755,9 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; + let wrap = flag(p, "wrap", false).then(|| doc.bounds()); let surf = paint_surface(doc, id, &json!({}))?; - Ok(apply_color_replacement(surf, &stroke, &rs, sel.as_ref(), lock)) + Ok(apply_color_replacement_wrapped(surf, &stroke, &rs, sel.as_ref(), lock, wrap)) })?; Ok(damage_json(s, dmg)) } @@ -762,8 +766,10 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { /// recolouring per dab as the commit, so the preview at the last point is the committed result. pub fn live_color_replacement(s: &Session, p: &Value) -> Result { let (stroke, rs, id) = color_replacement_setup(s, p)?; - crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", &json!({}), stroke, Some(id), 0, |pre| { - Box::new(color_replacement_effect(pre.clone(), rs)) + // Layer pixels, as the commit paints, wrapped as it is. + let params = json!({"wrap": flag(p, "wrap", false)}); + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", ¶ms, stroke, Some(id), 0, |pre, wrap| { + Box::new(color_replacement_effect(pre.clone(), rs, wrap)) })? .with_tail_preview() } @@ -1203,7 +1209,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, has_paintable, mixer_brush, true @@ -1213,7 +1219,7 @@ pub fn specs() -> Vec { spec!( "paint.colorReplacement", "Color Replacement", - r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 129753ea86f..653cd799930 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::selection as sel; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::Stroke; -use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser, background_eraser_effect, background_eraser_stroke}; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser_wrapped, background_eraser_effect, background_eraser_stroke}; use photocraft_paint::replace::{Limits, Sampling}; use serde_json::{Value, json}; @@ -152,10 +152,12 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { let (stroke, bs, id) = background_eraser_setup(s, p)?; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); + // View › Pattern Preview: the stroke carries on across the canvas edges. + let wrap = flag(p, "wrap", false).then(|| doc.bounds()); crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; - Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) + Ok(apply_background_eraser_wrapped(surf, &stroke, &bs, selection.as_ref(), wrap)) })?; Ok(damage(s, dmg, true)) } @@ -170,7 +172,9 @@ pub fn live_background_eraser(s: &Session, p: &Value) -> Result Box::new(effect), // No alpha: the commit erases nothing either. None => Box::new(|_: &mut photocraft_paint::retouch::Region, _: &photocraft_paint::retouch::Footprint| {}), @@ -196,7 +200,7 @@ pub fn specs() -> Vec { label: "Background Eraser", menu: &[], shortcut: None, - params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)}"##, enabled: has_pixel_layer, run: background_eraser, journal: true, diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index eae429f8c0c..4480f9b8b87 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -15,7 +15,8 @@ use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke}; +use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; +use photocraft_paint::wrap::wrap_coord; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -120,6 +121,10 @@ fn num(p: &Value, k: &str, d: f32) -> f32 { fn flag(p: &Value, k: &str, d: bool) -> bool { p.get(k).and_then(Value::as_bool).unwrap_or(d) } +/// The canvas a stroke with `"wrap": true` repeats across (View › Pattern Preview). +fn wrap_tile(pre: &Document, p: &Value) -> Option { + flag(p, "wrap", false).then(|| pre.bounds()).filter(|t| !t.is_empty()) +} fn string<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str { p.get(k).and_then(Value::as_str).unwrap_or(d) } @@ -382,26 +387,19 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = - run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { - if let Some(l) = live { - *surf = l.surface; - return Ok(l.bounds); - } - if flag(p, "wrap", false) { - let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); - renderer.push(&stroke.points); - renderer.finish(); - let before = surf.clone(); - let bounds = renderer.bounds(); - return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } - .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); - } - let (bounds, cov) = coverage(CMD, &stroke)?; - // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. - let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; - Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) - })?; + let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } + if flag(p, "wrap", false) { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + } + let (bounds, cov) = coverage(CMD, &stroke)?; + // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. + let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; + Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) + })?; let mut r = clone_result(dmg, off, aligned, p, &stroke); if from_live { r["fromLive"] = json!(true); @@ -520,11 +518,7 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { let bounds = self.renderer.bounds().union(&self.tail); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { - periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) - } else { - bounds - } + if flag(&self.params, "wrap", false) { periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) } else { bounds } } /// Every point rendered so far, in order. @@ -567,7 +561,7 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); - if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + if flag(&self.params, "wrap", false) { return self.push_periodic(); } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); @@ -596,8 +590,12 @@ impl LiveRetouch { let mut tail = self.renderer.tail_preview(); self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); dirty = dirty.union(&self.tail); - let LivePaint::Clone { which, map, cmd } = &self.paint else { - return Err(bad(&self.cmd, "periodic painting requires a clone source")); + let (pre, id, pre_surf, live_paint, live_cmd) = (&self.pre, self.id, &self.pre_surf, &self.paint, self.cmd.as_str()); + let fmt = pre_surf.format(); + let paint = |r| match live_paint { + LivePaint::Clone { which, map, cmd } => clone_sample(pre, id, pre_surf, *which, r, map, true, cmd), + LivePaint::History(src) => periodic::sample(pre, id, src, SampleLayers::Current, r, live_cmd), + LivePaint::Pattern { tile, place } => Ok(pattern_stamp::paint_region(tile, place, &fmt, r)), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; periodic::Composite { @@ -609,7 +607,7 @@ impl LiveRetouch { lock: self.lock, mode: self.mode, } - .draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd)) + .draw(dirty, &self.brush, &self.points, paint) } /// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`. @@ -678,15 +676,30 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { + // Pattern Preview: source and destination repeat across the canvas edges, so a stroke over + // a seam heals as one patch and goes on wherever it repeats. + let wrap = flag(p, "wrap", false); let (bounds, cov) = coverage(CMD, &stroke)?; let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); - let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; + let src = clone_sample(pre, id, surf, which, g, &map, wrap, CMD)?; // The destination the texture is fitted to: the layer itself, or what the user sees when // sampling several layers (so healing onto an empty layer matches the composite). - let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) }; + let dst = if wrap { + periodic::sample(pre, id, surf, which, g, CMD)? + } else if which == SampleLayers::Current { + Region::read(surf, g) + } else { + composite_region(pre, id, which, g, fmt) + }; let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); + if wrap { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| { + let part = healed.crop(r); + if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } + }); + } Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode)) })?; Ok(clone_result(dmg, off, aligned, p, &stroke)) @@ -714,13 +727,17 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; - let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { + let dmg = run_stroke(s, "History Brush", id, p, |pre, surf, sel, lock| { if let Some(l) = live { *surf = l.surface; return Ok(l.bounds); } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; + if flag(p, "wrap", false) { + // Pattern Preview: the history state repeats with the canvas, as the stroke does. + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); + } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) @@ -798,24 +815,63 @@ enum SpotType { /// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes /// its texture from around it, as Edit › Fill does from its window /// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples. -fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result { - let (bounds, cov) = coverage("paint.spotHealing", stroke)?; +/// +/// Wrapped around the canvas `wrap` (View › Pattern Preview), the window is centred on the stroke +/// whatever the canvas edges and at most one canvas across, so it holds no second copy of the +/// stroke; its pixels repeat across the edges, and the healed pixels go where they lie on the +/// canvas. +#[allow(clippy::too_many_arguments)] +fn spot_heal_surface( + surf: &mut Surface, + pre: &Document, + stroke: &Stroke, + kind: SpotType, + all: bool, + sel: Option<&Surface>, + lock: bool, + wrap: Option, +) -> Result { + const CMD: &str = "paint.spotHealing"; + let (bounds, cov) = coverage(CMD, stroke)?; let size = stroke.brush.size; - let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds()); + let region_rect = match wrap { + Some(tile) => { + let (tw, th) = (i64::from(tile.width()), i64::from(tile.height())); + let x0 = (i64::from(bounds.x0) + i64::from(bounds.x1) - tw) / 2; + let y0 = (i64::from(bounds.y0) + i64::from(bounds.y1) - th) / 2; + let around = Rect::new(x0 as i32, y0 as i32, (x0 + tw) as i32, (y0 + th) as i32); + crate::fill_cmds::sampling_window(bounds, around) + } + None => crate::fill_cmds::sampling_window(bounds, pre.bounds()), + }; if region_rect.is_empty() { return Ok(Rect::EMPTY); } // Proximity Match looks for its source anywhere in that window. let margin = (region_rect.width().max(region_rect.height()) / 2) as i32; let fmt = surf.format(); - let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) }; + let img = match wrap { + Some(_) => periodic::sample(pre, None, surf, if all { SampleLayers::All } else { SampleLayers::Current }, region_rect, CMD)?, + None if all => composite_region(pre, None, SampleLayers::All, region_rect, fmt), + None => Region::read(surf, region_rect), + }; let (w, h, ch) = (img.width(), img.height(), img.ch); let bw = bounds.width() as usize; let mut stroke_cov = vec![0.0f32; w * h]; for (i, c) in cov.iter().enumerate() { let (x, y) = (bounds.x0 + (i % bw) as i32, bounds.y0 + (i / bw) as i32); + // A wrapped stroke longer than the window (one canvas) folds onto it: the strongest + // coverage of each pixel heals it once. + let (x, y) = match wrap { + Some(t) => { + let wrap_into = |v: i32, lo: i32, period: u32| wrap_coord(v, lo, lo.saturating_add(period as i32)); + (wrap_into(x, region_rect.x0, t.width()), wrap_into(y, region_rect.y0, t.height())) + } + None => (x, y), + }; if region_rect.contains(x, y) { - stroke_cov[img.index(x, y)] = *c; + let px = &mut stroke_cov[img.index(x, y)]; + *px = px.max(*c); } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); @@ -843,8 +899,19 @@ fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: let src = Region { rect: region_rect, ch, data: filled }; let healed = heal_region(&fmt, &src, &img, &heal_mask); let dmg = bounds.intersect(®ion_rect); - let dmg_cov: Vec = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect(); - Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)) + let cov_of = |r: Rect, (dx, dy): (i32, i32)| -> Vec { + (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x + dx, y + dy)]).collect() + }; + let Some(tile) = wrap else { + return Ok(apply_coverage(surf, dmg, &cov_of(dmg, (0, 0)), stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal)); + }; + let mut done = Rect::EMPTY; + for (part, off) in photocraft_paint::wrap::pieces(dmg, tile) { + let mut paint = healed.crop(part.translate(off.0, off.1)); + paint.rect = part; + done = done.union(&apply_coverage(surf, part, &cov_of(part, off), stroke.brush.opacity, sel, lock, &paint, BlendMode::Normal)); + } + Ok(done) } fn spot_healing(s: &mut Session, p: &Value) -> Result { @@ -858,7 +925,8 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; let all = sample_all_layers(p); - let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?; + let dmg = + run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock, wrap_tile(pre, p)))?; Ok(json!({ "damage": damage_json(dmg) })) } @@ -1139,8 +1207,9 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { } let fmt = surf.format(); let mut effect = tool.effect(fmt, stroke.brush.spacing); - let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler { + let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); + let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); + Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), None => effect(work, fp), })) @@ -1152,81 +1221,6 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(r) } -/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]). -const WORK_TILE: i32 = 128; - -/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in -/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a -/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the -/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels. -struct WorkTiles { - ch: usize, - tiles: std::collections::HashMap<(i32, i32), Vec>, -} - -impl WorkTiles { - fn new(ch: usize) -> Self { - Self { ch, tiles: std::collections::HashMap::new() } - } - - fn tile_rect((tx, ty): (i32, i32)) -> Rect { - Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) - } - - /// The tiles `r` overlaps. - fn coords(r: Rect) -> impl Iterator { - let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); - let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); - let empty = r.is_empty(); - (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) - } - - /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. - fn read(&self, pre: &Surface, r: Rect) -> Region { - let mut out = Region::new(r, self.ch); - let n = self.ch; - let mut buf = Vec::new(); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - let tile = self.tiles.get(&c); - if tile.is_none() { - pre.read_region_into(part, &mut buf); - } - for y in part.y0..part.y1 { - let src = match tile { - Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), - None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), - }; - let dst = out.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - out - } - - /// Store `region` as the working pixels of its rectangle. - fn write(&mut self, pre: &Surface, region: &Region) { - let (r, n) = (region.rect, self.ch); - for c in Self::coords(r) { - let tr = Self::tile_rect(c); - let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); - let part = r.intersect(&tr); - let w = part.width() as usize * n; - for y in part.y0..part.y1 { - let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; - let src = region.index(part.x0, y) * n; - if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { - d.copy_from_slice(s); - } - } - } - } -} - /// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab /// runs the tool's effect on a working copy of the target, in order, and the changed area is /// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. @@ -1249,6 +1243,8 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, + /// The canvas a wrapped stroke repeats across (View › Pattern Preview, `"wrap": true`). + wrap: Option, /// Show the dabs finishing the stroke would add, so the preview at the last point is the /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to @@ -1278,13 +1274,14 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, _| effect.effect(pre.format(), spacing))?; live.tool = Some((tool, brush)); Ok(live) } - /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) - /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke + /// and the canvas a wrapped stroke repeats across) at each dab of `stroke`, reading `halo` + /// pixels around it. `p` gives the target and `wrap`. pub(crate) fn with_effect( s: &Session, cmd: &str, @@ -1292,7 +1289,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -1304,7 +1301,7 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let base = (st.doc.id, st.revision); @@ -1323,7 +1320,7 @@ impl LiveDab { stroke: Stroke, id: Option, halo: i32, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect) } @@ -1335,12 +1332,13 @@ impl LiveDab { stroke: Stroke, id: Option, neighborhood: DabNeighborhood, - effect: impl FnOnce(&Surface) -> DabEffect, + effect: impl FnOnce(&Surface, Option) -> DabEffect, ) -> Result { + let wrap = wrap_tile(&doc, p); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); - let effect = effect(&pre_surf); + let effect = effect(&pre_surf, wrap); // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); let mut live = Self { @@ -1350,7 +1348,7 @@ impl LiveDab { done: 0, effect, neighborhood, - work: WorkTiles::new(pre_surf.channels()), + work: WorkTiles::new(pre_surf.channels()).wrapped(wrap), pre_surf, id, params: p.clone(), @@ -1358,6 +1356,7 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, + wrap, preview_tail: false, tail: Rect::EMPTY, tool: None, @@ -1391,15 +1390,34 @@ impl LiveDab { if r.is_empty() { continue; } + let fp = ctx.footprint(d, self.done + k); let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d)); - (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); - self.work.write(&self.pre_surf, ®ion.crop(r)); - dmg = dmg.union(&r); + (self.effect)(&mut region, &fp); + self.work.write_dab(&self.pre_surf, ®ion, &fp); + dmg = dmg.union(&self.on_canvas(r)); } self.done += new.len(); dmg } + /// Where the stroke's rectangle `r` lands on the document: `r`, or in a wrapped stroke the + /// part of the canvas it covers. + fn on_canvas(&self, r: Rect) -> Rect { + self.wrap.map_or(r, |t| photocraft_paint::wrap::fold_area(r, t)) + } + + /// [`Self::show`] working pixels laid out in the stroke's own coordinates: a wrapped stroke + /// shows each part where it lies on the canvas. + fn show_unwrapped(&mut self, work: &Region) -> Result<()> { + let Some(tile) = self.wrap else { return self.show(work.rect, work) }; + for (part, (dx, dy)) in photocraft_paint::wrap::pieces(work.rect, tile) { + let mut piece = work.crop(part.translate(dx, dy)); + piece.rect = part; + self.show(part, &piece)?; + } + Ok(()) + } + /// Mix the working pixels of `r` back over the original into the shown document. fn show(&mut self, r: Rect, work: &Region) -> Result<()> { let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -1433,7 +1451,7 @@ impl LiveDab { // Back to the stroke without the previous tail. let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let shown = dmg.union(&old); - let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| self.on_canvas(*r)); if shown.is_empty() && tail_rect.is_empty() { return Ok(Rect::EMPTY); } @@ -1442,9 +1460,9 @@ impl LiveDab { let work = self.work.read(&self.pre_surf, shown); self.show(shown, &work)?; } - if let Some((r, work)) = tail { - self.show(r, &work)?; - self.tail = r; + if let Some((_, work)) = tail { + self.show_unwrapped(&work)?; + self.tail = tail_rect; } Ok(shown.union(&tail_rect)) } @@ -1490,26 +1508,56 @@ struct AllLayers<'a> { pre: &'a Document, fmt: PixelFormat, visible: Option, + /// A stroke wrapped around the canvas (View › Pattern Preview): the composite at full + /// precision under everything the stroke reads, and the dabs' edits of it, kept once per + /// pixel of the canvas as the layer's working pixels are. + wrapped: Option<(Surface, WorkTiles)>, } impl<'a> AllLayers<'a> { fn new(pre: &'a Document, fmt: PixelFormat) -> Self { - Self { pre, fmt, visible: None } + Self { pre, fmt, visible: None, wrapped: None } + } + + /// Sample for `stroke` wrapped around `wrap`, whose dabs read `halo` pixels around them. + fn wrapped(mut self, wrap: Option, stroke: &Stroke, halo: i32) -> Self { + let Some(tile) = wrap else { return self }; + let ctx = photocraft_paint::BrushContext::new(&stroke.brush); + let reach = photocraft_paint::dabs(stroke).iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))).inflate(halo); + let area = photocraft_paint::wrap::fold_area(reach, tile); + let mut visible = Surface::new(PixelFormat { sample: SampleType::F32, ..self.fmt }); + if !area.is_empty() { + visible.write_region(area, &composite_region(self.pre, None, SampleLayers::All, area, self.fmt).data); + } + self.wrapped = Some((visible, WorkTiles::new(self.fmt.channels()).wrapped(Some(tile)))); + self } fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) { + if let Some((visible, edits)) = self.wrapped.as_mut() { + let mut shown = edits.read(visible, work.rect); + effect(&mut shown, fp); + lay(work, &shown, fp); + edits.write_dab(visible, &shown, fp); + return; + } let (pre, fmt) = (self.pre, self.fmt); let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt)); if visible.rect != work.rect || visible.ch != work.ch { return effect(work, fp); } effect(visible, fp); - let r = fp.rect.intersect(&work.rect); - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - if fp.at(x, y) > 0.0 { - work.px_mut(x, y).copy_from_slice(visible.px(x, y)); - } + lay(work, visible, fp); + } +} + +/// Lay what a dab did to the visible pixels onto the layer's working pixels under it. +fn lay(work: &mut Region, visible: &Region, fp: &Footprint) { + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) > 0.0 { + work.px_mut(x, y).copy_from_slice(visible.px(x, y)); } } } @@ -1547,7 +1595,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]? → {"damage","phase","aligned","pattern"}"# + r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]?,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","phase","aligned","pattern"}"# ), enabled: pattern_stamp::enabled, run: pattern_stamp::run, @@ -1559,7 +1607,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"# + r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal","wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges) → {"damage","offset","aligned","nextSource"}"# ), enabled: has_pixel_layer, run: healing_brush, @@ -1570,7 +1618,9 @@ pub fn specs() -> Vec { label: "Spot Healing Brush", menu: &[], shortcut: None, - params: brush_params!(r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false"#), + params: brush_params!( + r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: spot_healing, journal: true, @@ -1612,7 +1662,9 @@ pub fn specs() -> Vec { label: "Dodge", menu: &[], shortcut: None, - params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), + params: brush_params!( + r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.dodge"), journal: true, @@ -1622,7 +1674,9 @@ pub fn specs() -> Vec { label: "Burn", menu: &[], shortcut: None, - params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), + params: brush_params!( + r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.burn"), journal: true, @@ -1632,7 +1686,9 @@ pub fn specs() -> Vec { label: "Sponge", menu: &[], shortcut: None, - params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#), + params: brush_params!( + r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sponge"), journal: true, @@ -1642,7 +1698,9 @@ pub fn specs() -> Vec { label: "Blur", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.blur"), journal: true, @@ -1652,7 +1710,9 @@ pub fn specs() -> Vec { label: "Sharpen", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.sharpen"), journal: true, @@ -1662,7 +1722,9 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false,"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, @@ -1673,7 +1735,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"# + r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error),"wrap":bool=false (View › Pattern Preview: the stroke goes on across the canvas edges)"# ), enabled: has_pixel_layer, run: history_brush, diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index f13afc112e3..7ccb293d4f7 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -8,12 +8,12 @@ use photocraft_color::{BlendMode, PixelFormat}; use photocraft_compose::pattern::{Placement, Tile, render}; use photocraft_doc::{LayerId, Pattern}; use photocraft_geom::Rect; -use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke, over_native}; +use photocraft_paint::retouch::{Footprint, Region, apply_coverage, apply_dab_stroke_wrapped, over_native}; use photocraft_paint::{Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde_json::{Value, json}; -use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, reject_huge_stroke}; +use super::{bad, blend_param, damage_json, flag, has_pixel_layer, parse_brush, periodic, reject_huge_stroke, wrap_tile}; use crate::{Result, Session}; const STREAM_PHASE_X: u64 = 101; @@ -88,10 +88,16 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { let pre = doc.clone(); let (surf, lock) = crate::channel_cmds::target_surface(doc, st.id, p)?; let sel = pre.selection.as_ref(); + // View › Pattern Preview: the stroke carries on across the canvas edges, the pattern + // following it as it goes. + let wrap = wrap_tile(&pre, p); if st.impressionist { // Halo is the dab footprint only: Impressionist jitters phase, it does not blur. let effect = st.impressionist_effect(surf.format()); - Ok(apply_dab_stroke(surf, &st.stroke, sel, lock, 0, effect)) + Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) + } else if wrap.is_some() { + let (place, fmt) = (st.placement(), surf.format()); + periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } @@ -119,7 +125,7 @@ pub fn live_impressionist(s: &Session, p: &Value) -> Result { } let (stroke, id) = (st.stroke.clone(), st.id); let doc = (*s.active().ok_or(crate::EngineError::NoDocument)?.doc).clone(); - super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre| st.impressionist_effect(pre.format()))?.with_tail_preview() + super::LiveDab::with_effect_on(doc, CMD, p, stroke, id, 0, |pre, _| st.impressionist_effect(pre.format()))?.with_tail_preview() } /// The pattern under destination `rect` in `fmt` (native channels): what an aligned stroke paints diff --git a/crates/engine/src/retouch_cmds/periodic.rs b/crates/engine/src/retouch_cmds/periodic.rs index f31542fb5ac..27f4c9c86c2 100644 --- a/crates/engine/src/retouch_cmds/periodic.rs +++ b/crates/engine/src/retouch_cmds/periodic.rs @@ -23,6 +23,31 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect() } +/// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a +/// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and +/// they go on through the stroke's coverage wherever it repeats on the document. +pub(super) fn stroke( + pre: &Document, + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock: bool, + mode: BlendMode, + mut paint: impl FnMut(&Surface, Rect) -> Result, +) -> Result { + let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); + renderer.push(&stroke.points); + renderer.finish(); + let before = target.clone(); + let bounds = renderer.bounds(); + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode }.draw( + bounds, + &stroke.brush, + &stroke.points, + |r| paint(&before, r), + ) +} + /// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels. /// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and /// selection are applied once after all dabs, including repeated visits through other tiles. diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 524e12f0802..e88129cb67c 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -2407,3 +2407,292 @@ fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { } } } + +// --------------------------------------------------------------------------------------------- +// View › Pattern Preview: every retouching stroke wraps across the canvas edges (#8883) +// --------------------------------------------------------------------------------------------- + +/// The canvas the Pattern Preview tests wrap around. +const WRAP: (u32, u32) = (48, 36); + +/// `texture` repeated every canvas, so each copy of a pixel holds the same colour. +fn tiled_texture(x: i32, y: i32) -> [f32; 4] { + texture(x.rem_euclid(WRAP.0 as i32), y.rem_euclid(WRAP.1 as i32)) +} + +/// A stroke from just inside the corner where the middle copy of a three-by-three tiling meets +/// the next copies, out across both of its edges. +fn corner_stroke(extra: &Value) -> Value { + let mut p = json!({"points": [[84, 64], [106, 79]], "size": 12, "hardness": 100}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + p +} + +/// The texture on the layer. +fn textured(s: &mut Session) { + paint_layer(s, tiled_texture); +} + +/// The texture under an empty layer, which is the target (Sample All Layers). +fn textured_below(s: &mut Session) { + paint_layer(s, tiled_texture); + s.execute("layer.new.layer", json!({})).unwrap(); +} + +/// The texture in history state 1, painted over since. +fn textured_in_history(s: &mut Session) { + paint_layer(s, tiled_texture); + paint_layer(s, |_, _| [0.15, 0.2, 0.7, 1.0]); +} + +type Setup = fn(&mut Session); + +/// Every stroke tool that wraps, with options and a document for it. `true` marks the tools +/// that composite a paint image through the stroke's coverage (`periodic::Composite`), which +/// matches the ordinary composite only within rounding. +fn wrap_cases() -> Vec<(&'static str, Value, Setup, bool)> { + vec![ + ("paint.dodge", json!({"exposure": 60}), textured, false), + ("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false}), textured, false), + ("paint.sponge", json!({"mode": "saturate"}), textured, false), + ("paint.blur", json!({"strength": 80}), textured, false), + ("paint.sharpen", json!({"strength": 60}), textured, false), + ("paint.smudge", json!({"strength": 70}), textured, false), + ("paint.blur", json!({"strength": 80, "sampleAllLayers": true}), textured_below, false), + ("paint.smudge", json!({"strength": 70, "sampleAllLayers": true}), textured_below, false), + ("paint.mixerBrush", json!({"wet": 100, "mix": 80, "load": 50}), textured, false), + ("paint.mixerBrush", json!({"wet": 100, "mix": 80, "sampleAllLayers": true}), textured_below, false), + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), textured, false), + ("paint.backgroundEraser", json!({"tolerance": 40}), textured, false), + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true}), textured, false), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured, true), + ("paint.historyBrush", json!({"state": 1}), textured_in_history, true), + ("paint.healingBrush", json!({"offset": [-20, -14]}), textured, true), + ("paint.cloneStamp", json!({"offset": [-20, -14]}), textured, true), + ] +} + +/// The native-channel difference `periodic::Composite` may leave against the ordinary composite. +fn composite_tolerance(depth: u64) -> f32 { + match depth { + 8 => 1.1 / 255.0, + 16 => 1.1 / 65535.0, + _ => 0.00001, + } +} + +#[test] +fn every_wrapping_stroke_paints_the_canvas_as_on_the_canvas_tiled_three_by_three() { + // A stroke with `"wrap": true` gives each pixel of the canvas what the same stroke gives the + // copy of that pixel it goes over, on a document holding the canvas tiled three by three. + let (w, h) = (WRAP.0 as usize, WRAP.1 as usize); + for depth in DEPTHS { + for (cmd, extra, setup, composite) in wrap_cases() { + let mut canvas = session(WRAP.0, WRAP.1, depth, "rgb"); + setup(&mut canvas); + let mut tiled = session(3 * WRAP.0, 3 * WRAP.1, depth, "rgb"); + setup(&mut tiled); + let (mut wrapped, plain) = (corner_stroke(&extra), corner_stroke(&extra)); + wrapped["wrap"] = json!(true); + canvas.execute(cmd, wrapped).unwrap_or_else(|e| panic!("{cmd} at {depth}: {e}")); + let before = layer_pixels(&tiled); + tiled.execute(cmd, plain).unwrap(); + let (got, want) = (layer_pixels(&canvas), layer_pixels(&tiled)); + let n = got.len() / (w * h); + let at = |x: usize, y: usize| (y * 3 * w + x) * n..(y * 3 * w + x + 1) * n; + let tolerance = if composite { composite_tolerance(depth) } else { 0.0 }; + let (mut moved, mut edges) = (0, [false; 4]); + for y in 0..h { + for x in 0..w { + let copies = (0..3).flat_map(|j| (0..3).map(move |i| (x + i * w, y + j * h))); + let changed: Vec<_> = copies.filter(|&(u, v)| want[at(u, v)] != before[at(u, v)]).collect(); + let (pixel, expected) = match changed.as_slice() { + [] => (&got[(y * w + x) * n..][..n], &before[at(x, y)]), + [(u, v)] => (&got[(y * w + x) * n..][..n], &want[at(*u, *v)]), + _ => panic!("{cmd}: ({x}, {y}) has two copies under the stroke"), + }; + let error = pixel.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: ({x}, {y}) is off by {error}"); + if !changed.is_empty() { + moved += 1; + edges[0] |= x < 8; + edges[1] |= x >= w - 8; + edges[2] |= y < 8; + edges[3] |= y >= h - 8; + } + } + } + assert!(moved > 150 && edges.iter().all(|&e| e), "{cmd} {extra} at {depth}-bit: the stroke crossed both edges ({moved} px, {edges:?})"); + } + } +} + +#[test] +fn a_wrapped_stroke_inside_the_canvas_paints_as_an_ordinary_one() { + let p = json!({"points": [[130, 110], [150, 118], [168, 112]], "size": 14, "hardness": 60}); + let mut cases: Vec<(&str, Value, Setup, bool)> = wrap_cases(); + cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); + cases.push(("paint.spotHealing", json!({"type": "proximityMatch"}), textured, false)); + for depth in [8, 32] { + for (cmd, extra, setup, composite) in &cases { + let run = |wrap: bool| { + // Big enough that Spot Healing's window stays clear of the edges either way. + let mut s = session(300, 230, depth, "rgb"); + setup(&mut s); + let mut q = p.clone(); + for (k, v) in extra.as_object().unwrap() { + q[k] = v.clone(); + } + q["wrap"] = json!(wrap); + let r = s.execute(cmd, q).unwrap(); + (layer_pixels(&s), r["damage"].clone()) + }; + let ((plain, d0), (wrapped, d1)) = (run(false), run(true)); + let tolerance = if *composite { composite_tolerance(depth) } else { 0.0 }; + let error = plain.iter().zip(&wrapped).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= tolerance, "{cmd} {extra} at {depth}-bit: off by {error}"); + if !*composite { + assert_eq!(d0, d1, "{cmd} {extra} at {depth}-bit: the same damage"); + } + } + } +} + +#[test] +fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_nothing_else() { + // The window is centred on the stroke whatever the edges and one canvas across, so the + // corner heals from what surrounds it in the copies, the same as when the stroke stays inside. + for kind in ["contentAware", "createTexture", "proximityMatch"] { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + paint_layer(&mut s, |x, y| if (x - 2).abs() < 3 && (y - 2).abs() < 3 { [0.0, 0.0, 0.0, 1.0] } else { tiled_texture(x, y) }); + let before = layer_pixels(&s); + // The dark blemish straddles the corner (0, 0); the stroke is drawn on the copy to its + // lower right, around (48, 36). + s.execute("paint.spotHealing", json!({"points": [[48, 36], [50, 38]], "size": 14, "hardness": 100, "type": kind, "wrap": true})).unwrap(); + let after = layer_pixels(&s); + let n = after.len() / (WRAP.0 * WRAP.1) as usize; + let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); + for (x, y) in [(1, 1), (46, 1), (1, 34), (46, 34)] { + assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); + } + for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { + assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); + } + } +} + +#[test] +fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { + let mut cases = wrap_cases(); + cases.push(("paint.spotHealing", json!({"type": "contentAware"}), textured, false)); + for (cmd, extra, setup, _) in cases { + let mut s = session(16, 12, 8, "rgb"); + setup(&mut s); + let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + let d = r["damage"].as_array().map(|d| d.iter().filter_map(Value::as_i64).collect::>()).unwrap_or_default(); + assert!(matches!(d.as_slice(), [x, y, w, h] if *x >= 0 && *y >= 0 && x + w <= 16 && y + h <= 12), "{cmd}: damage {d:?} is on the canvas"); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + } +} + +#[test] +fn wrapped_live_strokes_show_what_their_commits_paint() { + // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the + // pixels they drew; the strokes that preview their finishing dabs show their commit's. + let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; + for depth in DEPTHS { + for (cmd, extra) in dab_cases() { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut extra = extra.clone(); + extra["wrap"] = json!(true); + let rendered = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); + let want = layer_pixels(&s); + s.execute("edit.undo", json!({})).unwrap(); + offer_live(&mut s, cmd, &points, 50, &extra); + let taken = s.execute(cmd, dab_opts(&points, 50, &extra)).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{cmd} at {depth}-bit"); + assert_eq!(taken["damage"], rendered["damage"], "{cmd} at {depth}-bit"); + assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit: the live pixels are the command's"); + } + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + type Begin = fn(&Session, &Value) -> Result; + let shown: [(&str, Value, Begin); 3] = [ + ("paint.colorReplacement", json!({"color": "#ff8000", "tolerance": 60}), crate::brush_cmds::live_color_replacement), + ("paint.backgroundEraser", json!({"tolerance": 40}), crate::eraser_cmds::live_background_eraser), + // A seed, as the canvas sends: without one the jitter hashes all the points. + ("paint.patternStamp", json!({"pattern": "Bricks", "impressionist": true, "seed": 7}), live_pattern_impressionist), + ]; + for (cmd, extra, begin) in shown { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + paint_layer(&mut s, tiled_texture); + let mut p = dab_opts(&points[..1], 50, &extra); + p["wrap"] = json!(true); + let mut live = begin(&s, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + let shown = live.doc.clone(); + let pre = s.active().unwrap().doc.clone(); + p["points"] = json!(points); + s.execute(cmd, p).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), st.doc.layer(id).unwrap().surface().unwrap()); + let before = pre.layer(id).unwrap().surface().unwrap(); + let worst = (0..WRAP.1 as i32) + .flat_map(|y| (0..WRAP.0 as i32).map(move |x| (x, y))) + .map(|(x, y)| (0..4).map(|c| (a.rgba(x, y)[c] - b.rgba(x, y)[c]).abs()).fold(0.0f32, f32::max)) + .fold(0.0f32, f32::max); + assert!(worst <= 2.0 / 255.0 + 1e-6, "{cmd} at {depth}-bit: live and commit differ by {worst}"); + let right = WRAP.0 as i32 - 3; + assert!((0..WRAP.1 as i32).any(|y| b.rgba(right, y) != before.rgba(right, y)), "{cmd} at {depth}-bit: the stroke came back at the right edge"); + } + } +} + +#[test] +fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { + // History Brush commits the pixels its live stroke drew across the edges; the aligned Pattern + // Stamp's preview is its commit. + let points = [[30.0, 20.0], [10.0, 30.0], [-6.0, 40.0], [-12.0, 34.0]]; + let pts: Vec<_> = points.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect(); + for depth in DEPTHS { + for (cmd, extra, setup) in [ + ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), + ] { + let mut s = session(WRAP.0, WRAP.1, depth, "rgb"); + setup(&mut s); + let mut p = dab_opts(&points[..1], 50, &extra); + p["wrap"] = json!(true); + let mut live = LiveRetouch::begin(&s, cmd, &p).unwrap(); + live.push(&pts[1..]).unwrap(); + p["points"] = json!(points); + let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 36)); + let plain = s.execute(cmd, p.clone()).unwrap(); + let want = layer_pixels(&s); + let error = shown.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(error <= composite_tolerance(depth), "{cmd} at {depth}-bit: the preview is off by {error}"); + if cmd == "paint.historyBrush" { + // A stroke drawn on the document as it is after the undo. + s.execute("edit.undo", json!({})).unwrap(); + let mut first = p.clone(); + first["points"] = json!(points[..1]); + let mut live = LiveRetouch::begin(&s, cmd, &first).unwrap(); + live.push(&pts[1..]).unwrap(); + s.offer_live_retouch(live).unwrap(); + let taken = s.execute(cmd, p).unwrap(); + assert_eq!(taken["fromLive"], json!(true), "{depth}-bit"); + assert_eq!(taken["damage"], plain["damage"], "{depth}-bit"); + assert!(layer_pixels(&s) == want, "{depth}-bit: the committed live pixels are the command's"); + } + } + } +} diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index 323a90036aa..e41308e76a5 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke_wrapped}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,8 +49,14 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - let Some(effect) = background_eraser_effect(target, bs) else { return Rect::EMPTY }; - apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) + apply_background_eraser_wrapped(target, stroke, bs, selection, None) +} + +/// [`apply_background_eraser`] wrapped around `wrap` (View › Pattern Preview): the stroke samples +/// and erases where its pixels lie on that canvas, so it carries on across the edges. +pub fn apply_background_eraser_wrapped(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>, wrap: Option) -> Rect { + let Some(effect) = background_eraser_effect(target, bs, wrap) else { return Rect::EMPTY }; + apply_dab_stroke_wrapped(target, &background_eraser_stroke(stroke), selection, false, 0, wrap, effect) } /// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), @@ -64,8 +70,9 @@ pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { /// One Background Eraser dab's edit of a working copy of `pre` (the target before the stroke): /// shared by [`apply_background_eraser`] and live previews that run the dabs as they come, so both -/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). -pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option> { +/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). In a +/// stroke wrapped around `wrap`, `pre` is read where the working pixels lie on that canvas. +pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings, wrap: Option) -> Option> { let fmt = pre.format(); let a = alpha_index(&fmt)?; let nc = a; @@ -97,7 +104,7 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option, @@ -101,7 +105,9 @@ pub fn apply_mixer_stroke( state: &mut MixerState, selection: Option<&Surface>, lock_transparency: bool, + wrap: Option, ) -> Rect { + let wrap = wrap.filter(|t| !t.is_empty()); let fmt = target.format(); let n = fmt.channels(); let a = alpha_index(&fmt); @@ -121,7 +127,7 @@ pub fn apply_mixer_stroke( st.brush.opacity = 1.0; let mut acc = vec![0.0f32; n]; let mut paint = vec![0.0f32; n]; - let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + let dmg = apply_dab_stroke_wrapped(target, &st, selection, lock_transparency, 0, wrap, |work, fp| { // Transfer › Wetness / Mix Jitter scale the settings per dab. let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); // Sample the canvas under the dab. @@ -136,6 +142,7 @@ pub fn apply_mixer_stroke( } let px: Vec = match sample { Some(s) => { + let (x, y) = wrap.map_or((x, y), |t| (wrap_coord(x, t.x0, t.x1), wrap_coord(y, t.y0, t.y1))); let v = s.pixel(x, y); if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } } @@ -202,7 +209,16 @@ mod tests { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); st.load([1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + apply_mixer_stroke( + &mut s, + None, + &stroke(10.0), + &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, + &mut st, + None, + false, + None, + ); let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]); assert!(start > 0.95, "{start}"); if expect_dry { @@ -231,6 +247,7 @@ mod tests { &mut st, None, false, + None, ); // Blue is dragged into the white half. let c = s.rgba(115, 10); @@ -252,7 +269,7 @@ mod tests { // On red, the clean wet brush smears red and lays down none of the blue it was loaded with. let mut s = Surface::new(PixelFormat::RGBA8); s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]); - apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false, None); let c = s.rgba(100, 10); assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); } @@ -288,7 +305,7 @@ mod tests { fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); let mut st = MixerState::default(); - let dmg = apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, ..Default::default() }, &mut st, None, false); + let dmg = apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, ..Default::default() }, &mut st, None, false, None); assert!(!dmg.is_empty()); assert_eq!(s.rgba(50, 10)[3], 0.0); } @@ -306,7 +323,7 @@ mod tests { for p in &mut k.points { p.pressure = pressure; } - apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false); + apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false, None); st.pickup }; assert!(run(0.0).is_none()); diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 20619cd48a0..957037a0bd3 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,7 +8,8 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Footprint, Region, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, apply_dab_stroke_wrapped}; +use crate::wrap::wrap_coord; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -79,14 +80,29 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - let effect = color_replacement_effect(target.clone(), rs.clone()); - apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) + apply_color_replacement_wrapped(target, stroke, rs, selection, lock_transparency, None) +} + +/// [`apply_color_replacement`] wrapped around `wrap` (View › Pattern Preview): the stroke samples +/// and recolours where its pixels lie on that canvas, so it carries on across the edges. +pub fn apply_color_replacement_wrapped( + target: &mut Surface, + stroke: &Stroke, + rs: &ReplaceSettings, + selection: Option<&Surface>, + lock_transparency: bool, + wrap: Option, +) -> Rect { + let effect = color_replacement_effect(target.clone(), rs.clone(), wrap); + apply_dab_stroke_wrapped(target, stroke, selection, lock_transparency, 0, wrap, effect) } /// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before /// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they -/// come, so both recolour the same pixels. -pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { +/// come, so both recolour the same pixels. In a stroke wrapped around `wrap` (View › Pattern +/// Preview), `pre` is read where the dab's pixels lie on that canvas. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings, wrap: Option) -> impl FnMut(&mut Region, &Footprint) { + let wrap = wrap.filter(|t| !t.is_empty()); let fmt = pre.format(); let n = fmt.channels(); let mode = match rs.mode { @@ -111,13 +127,14 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut }; move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); + let (sx, sy) = wrap.map_or((cx, cy), |t| (wrap_coord(cx, t.x0, t.x1), wrap_coord(cy, t.y0, t.y1))); let sampled = match rs.sampling { Sampling::Continuous => { - let c = pre.rgba(cx, cy); + let c = pre.rgba(sx, sy); [c[0], c[1], c[2]] } Sampling::Once => *once.get_or_insert_with(|| { - let c = pre.rgba(cx, cy); + let c = pre.rgba(sx, sy); [c[0], c[1], c[2]] }), Sampling::BackgroundSwatch => [rs.background[0], rs.background[1], rs.background[2]], @@ -127,8 +144,15 @@ pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut return; } let (w, h) = (r.width() as usize, r.height() as usize); - let mut pre_px = vec![[0.0f32; 4]; w * h]; - pre.read_rgba_into(r, &mut pre_px); + let read = |q: Rect| { + let mut px = vec![[0.0f32; 4]; q.width() as usize * q.height() as usize]; + pre.read_rgba_into(q, &mut px); + px + }; + let pre_px = match wrap { + Some(t) => crate::wrap::read(r, t, 1, read), + None => read(r), + }; // Per-pixel match weight. let mut weight = vec![0.0f32; w * h]; for y in r.y0..r.y1 { diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 06bafe6cfbb..09c38fc78a1 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -14,10 +14,13 @@ //! All pixel data here is the surface's *native* channels as normalised `f32` (any depth, any colour //! model); colour-space-specific work happens in the callbacks. +use std::collections::HashMap; + use photocraft_color::{BlendMode, PixelFormat}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use crate::wrap::{fits, fold_area, wrap_coord}; use crate::{Dab, Stroke, dab_coverage, dabs}; /// A rectangle of native-channel pixels (`rect.width() × rect.height() × ch` floats, row-major). @@ -75,6 +78,14 @@ impl Region { pub fn write(&self, s: &mut Surface) { s.write_region(self.rect, &self.data); } + /// Read `rect` from a surface that repeats `wrap` apart (View › Pattern Preview): `rect` may + /// cross its edges. `None` (or an empty tile) is [`Region::read`]. + pub fn read_wrapped(s: &Surface, rect: Rect, wrap: Option) -> Self { + match wrap.filter(|t| !t.is_empty()) { + Some(tile) => Self { rect, ch: s.channels(), data: crate::wrap::read(rect, tile, s.channels(), |r| s.read_region(r)) }, + None => Self::read(s, rect), + } + } } /// One dab's footprint: coverage (hardness falloff × dab alpha) over its bounding rectangle. @@ -266,6 +277,174 @@ pub fn apply_dab_stroke( bounds } +/// [`apply_dab_stroke`] wrapped around `wrap` (View › Pattern Preview): each dab reads and edits +/// the pixels where it repeats on that tile, so a stroke crossing an edge carries on at the +/// opposite one. The dabs run one by one on a [`WorkTiles`] working copy, as live previews run +/// them. `None` (or an empty tile) is [`apply_dab_stroke`]. Returns the damaged rectangle. +pub fn apply_dab_stroke_wrapped( + target: &mut Surface, + stroke: &Stroke, + selection: Option<&Surface>, + lock_transparency: bool, + halo: i32, + wrap: Option, + mut effect: impl FnMut(&mut Region, &Footprint), +) -> Rect { + let Some(tile) = wrap.filter(|t| !t.is_empty()) else { + return apply_dab_stroke(target, stroke, selection, lock_transparency, halo, effect); + }; + let ctx = crate::BrushContext::new(&stroke.brush); + let mut work = WorkTiles::new(target.channels()).wrapped(Some(tile)); + let mut damage = Rect::EMPTY; + for (i, d) in dabs(stroke).iter().enumerate() { + let fp = ctx.footprint(d, i); + if fp.rect.is_empty() { + continue; + } + let mut region = work.read(target, fp.rect.inflate(halo)); + effect(&mut region, &fp); + work.write_dab(target, ®ion, &fp); + damage = damage.union(&fold_area(fp.rect, tile)); + } + let written: Vec = work.areas().map(|r| r.intersect(&tile)).filter(|r| !r.is_empty()).collect(); + for r in written { + let edited = work.read(target, r); + mix_back(target, &Region::read(target, r), &edited, r, stroke.brush.opacity, selection, lock_transparency); + } + damage +} + +/// Side of the tiles a [`WorkTiles`] working copy keeps its pixels in. +const WORK_TILE: i32 = 128; + +/// The working copy of a sequential-dab stroke run dab by dab (live previews, +/// [`apply_dab_stroke_wrapped`]): the target's native channels as `f32`, in tiles copied from the +/// pre-stroke surface the first time a dab writes to them. Unlike a `Surface`, it isn't rounded to +/// the document's depth between dabs, so each dab sees what [`apply_dab_stroke`]'s working copy +/// holds. Wrapped around a tile ([`Self::wrapped`]), it keeps each pixel once, where it lies on +/// the tile, and reads and writes it wherever it repeats. +pub struct WorkTiles { + ch: usize, + tiles: HashMap<(i32, i32), Vec>, + wrap: Option, +} + +impl WorkTiles { + pub fn new(ch: usize) -> Self { + Self { ch, tiles: HashMap::new(), wrap: None } + } + + /// Keep the pixels of a stroke wrapped around `tile` (View › Pattern Preview). `None` or an + /// empty tile doesn't wrap. + pub fn wrapped(mut self, tile: Option) -> Self { + self.wrap = tile.filter(|t| !t.is_empty()); + self + } + + fn tile_rect((tx, ty): (i32, i32)) -> Rect { + Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE) + } + + /// The tiles `r` overlaps. + fn coords(r: Rect) -> impl Iterator { + let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE)); + let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE)); + let empty = r.is_empty(); + (y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty))) + } + + /// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet. + /// Wrapped, `r` may cross the tile's edges or be bigger than it. + pub fn read(&self, pre: &Surface, r: Rect) -> Region { + match self.wrap { + Some(tile) => Region { rect: r, ch: self.ch, data: crate::wrap::read(r, tile, self.ch, |part| self.read_at(pre, part).data) }, + None => self.read_at(pre, r), + } + } + + fn read_at(&self, pre: &Surface, r: Rect) -> Region { + let mut out = Region::new(r, self.ch); + let n = self.ch; + let mut buf = Vec::new(); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + let tile = self.tiles.get(&c); + if tile.is_none() { + pre.read_region_into(part, &mut buf); + } + for y in part.y0..part.y1 { + let src = match tile { + Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)), + None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)), + }; + let dst = out.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + out + } + + /// Store a dab's edit: the pixels of `region` (which covers the dab) over the dab's rectangle + /// `fp.rect`. Wrapped, they land where they lie on the tile; a dab bigger than the tile covers + /// some pixels more than once, and each keeps the copy the dab covers most. + pub fn write_dab(&mut self, pre: &Surface, region: &Region, fp: &Footprint) { + let r = fp.rect.intersect(®ion.rect); + let Some(tile) = self.wrap else { + return self.write_at(pre, ®ion.crop(r)); + }; + if fits(r, tile) { + for (part, (dx, dy)) in crate::wrap::pieces(r, tile) { + let mut piece = region.crop(part.translate(dx, dy)); + piece.rect = part; + self.write_at(pre, &piece); + } + return; + } + let area = fold_area(r, tile); + let mut folded = self.read_at(pre, area); + let mut most = vec![0.0f32; area.width() as usize * area.height() as usize]; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (c, tx, ty) = (fp.at(x, y), wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); + if let Some(m) = most.get_mut(folded.index(tx, ty)) + && c > *m + { + *m = c; + folded.px_mut(tx, ty).copy_from_slice(region.px(x, y)); + } + } + } + self.write_at(pre, &folded); + } + + /// Store `region` as the working pixels of its rectangle. + fn write_at(&mut self, pre: &Surface, region: &Region) { + let (r, n) = (region.rect, self.ch); + for c in Self::coords(r) { + let tr = Self::tile_rect(c); + let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr)); + let part = r.intersect(&tr); + let w = part.width() as usize * n; + for y in part.y0..part.y1 { + let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n; + let src = region.index(part.x0, y) * n; + if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) { + d.copy_from_slice(s); + } + } + } + } + + /// The rectangles of the tiles dabs have written to. + pub fn areas(&self) -> impl Iterator + '_ { + self.tiles.keys().map(|&c| Self::tile_rect(c)) + } +} + /// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at /// `opacity × selection` inside `rect`, and write that area to `target`: the last step of /// [`apply_dab_stroke`], shared with live previews that run the dabs as they come. @@ -637,4 +816,89 @@ mod tests { } } } + + /// The canvas the Pattern Preview strokes below wrap around. + const TILE: Rect = Rect::new(0, 0, 40, 30); + + /// `area` of a surface showing the canvas repeated: every pixel of the canvas has its own colour. + fn tiled(fmt: PixelFormat, area: Rect) -> Surface { + let mut s = Surface::new(fmt); + let px = (area.y0..area.y1).flat_map(|y| (area.x0..area.x1).map(move |x| (wrap_coord(x, 0, 40), wrap_coord(y, 0, 30)))); + let data: Vec = px.flat_map(|(x, y)| [x as f32 / 40.0, y as f32 / 30.0, ((x * 7 + y * 3) % 13) as f32 / 13.0, 1.0]).collect(); + s.write_region(area, &data); + s + } + + fn smudge(size: f32) -> impl FnMut(&mut Region, &Footprint) { + let mut sm = Smudge::new(0.8, None, size).with_alpha(Some(3)); + move |work, fp| sm.dab(work, fp) + } + + #[test] + fn a_wrapped_sequential_stroke_paints_the_canvas_as_its_tiled_copies_would() { + // View › Pattern Preview: a smudge across a corner of the canvas copy it is drawn on gives + // the canvas exactly what it gives the canvas tiled three by three, where it runs into the + // next copies and carries their colours along. (The same points both times: moved points + // would place the dabs a rounding apart.) + let fmt = PixelFormat::RGBA32F; + let far = stroke(&[(68.0, 48.0), (92.0, 70.0)], 12.0, 0.5); + let halo = Smudge::sampling_radius(12.0); + let mut canvas = tiled(fmt, TILE); + let dmg = apply_dab_stroke_wrapped(&mut canvas, &far, None, false, halo, Some(TILE), smudge(12.0)); + let three = Rect::new(0, 0, 120, 90); + let (before, mut big) = (tiled(fmt, three), tiled(fmt, three)); + apply_dab_stroke(&mut big, &far, None, false, halo, smudge(12.0)); + let mut changed = 0; + for y in 0..30 { + for x in 0..40 { + let copies = [(40, 30), (80, 30), (40, 60), (80, 60)].map(|(dx, dy)| (x + dx, y + dy)); + let moved: Vec<_> = copies.into_iter().filter(|&(u, v)| big.pixel(u, v) != before.pixel(u, v)).collect(); + let want = match moved.as_slice() { + [] => before.pixel(x, y), + [(u, v)] => big.pixel(*u, *v), + _ => panic!("({x}, {y}): two copies under the stroke"), + }; + assert_eq!(canvas.pixel(x, y), want, "({x}, {y})"); + changed += usize::from(!moved.is_empty()); + } + } + assert!(changed > 300, "the smudge crossed both edges ({changed} px)"); + assert!(dmg.x0 >= 0 && dmg.y0 >= 0 && dmg.x1 <= 40 && dmg.y1 <= 30, "{dmg:?}"); + assert!((-12..0).all(|x| canvas.pixel(x, 20)[3] == 0.0) && (40..52).all(|x| canvas.pixel(x, 20)[3] == 0.0), "nothing off the canvas"); + } + + #[test] + fn a_wrapped_sequential_stroke_inside_the_canvas_paints_as_before() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { + let st = stroke(&[(16.0, 12.0), (24.0, 18.0)], 6.0, 0.8); + let run = |wrap| { + let mut s = tiled(fmt, TILE); + let dmg = apply_dab_stroke_wrapped(&mut s, &st, None, false, Smudge::sampling_radius(6.0), wrap, smudge(6.0)); + (s, dmg) + }; + let ((plain, d0), (wrapped, d1)) = (run(None), run(Some(TILE))); + assert_eq!(d0, d1, "{fmt:?}"); + assert!((0..30).all(|y| (0..40).all(|x| plain.pixel(x, y) == wrapped.pixel(x, y))), "{fmt:?}"); + assert!(plain.pixel(20, 15) != tiled(fmt, TILE).pixel(20, 15), "{fmt:?}: the stroke smudged"); + } + } + + #[test] + fn a_wrapped_dab_bigger_than_the_canvas_edits_all_of_it_and_nothing_else() { + let small = Rect::new(0, 0, 10, 8); + let mut s = Surface::new(PixelFormat::RGBA32F); + s.fill_rect(small, &[1.0, 0.0, 0.0, 1.0]); + let darken = |work: &mut Region, fp: &Footprint| { + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + work.px_mut(x, y)[0] *= 1.0 - 0.5 * fp.at(x, y); + } + } + }; + let dmg = apply_dab_stroke_wrapped(&mut s, &stroke(&[(5.0, 4.0), (9.0, 6.0)], 30.0, 0.0), None, false, 0, Some(small), darken); + assert_eq!(dmg, small); + assert!((0..8).all(|y| (0..10).all(|x| s.pixel(x, y)[0] < 1.0)), "every pixel under the dab's copies darkens"); + assert!((-5..15).all(|x| s.pixel(x, -1)[3] == 0.0 && s.pixel(x, 8)[3] == 0.0), "nothing off the canvas"); + } } diff --git a/crates/paint/src/wrap.rs b/crates/paint/src/wrap.rs new file mode 100644 index 00000000000..efad49d3cad --- /dev/null +++ b/crates/paint/src/wrap.rs @@ -0,0 +1,166 @@ +//! View › Pattern Preview geometry: the document repeats its canvas (the *tile*) in every +//! direction, so a stroke past one edge carries on at the opposite one. Coordinates outside the +//! tile name the pixel a whole number of tile sizes away that lies on it. + +use photocraft_geom::Rect; + +/// `v` moved a whole number of periods into `lo..hi` (`v` itself when that is empty). +#[inline] +pub fn wrap_coord(v: i32, lo: i32, hi: i32) -> i32 { + let period = i64::from(hi) - i64::from(lo); + if period <= 0 { + return v; + } + (i64::from(lo) + (i64::from(v) - i64::from(lo)).rem_euclid(period)) as i32 +} + +/// Whether `rect` fits in `tile`, so it covers no pixel of the tile twice. +pub fn fits(rect: Rect, tile: Rect) -> bool { + rect.width() <= tile.width() && rect.height() <= tile.height() +} + +/// The part of `tile` that `rect` covers once moved onto it: per axis, the rectangle's span moved +/// a whole number of periods, or the whole tile where the span is longer or crosses an edge. +pub fn fold_area(rect: Rect, tile: Rect) -> Rect { + if rect.is_empty() || tile.is_empty() { + return Rect::EMPTY; + } + let span = |a0: i32, a1: i32, lo: i32, hi: i32| { + let start = wrap_coord(a0, lo, hi); + let len = i64::from(a1) - i64::from(a0); + if i64::from(start) + len > i64::from(hi) { (lo, hi) } else { (start, start + len as i32) } + }; + let (x0, x1) = span(rect.x0, rect.x1, tile.x0, tile.x1); + let (y0, y1) = span(rect.y0, rect.y1, tile.y0, tile.y1); + Rect::new(x0, y0, x1, y1) +} + +/// One axis of `a0..a1` cut where it crosses the edges of `lo..hi`: each run's start on the tile, +/// its start in `a0..a1`, and its length. +fn runs(a0: i32, a1: i32, lo: i32, hi: i32) -> Vec<(i32, i32, i32)> { + let period = i64::from(hi) - i64::from(lo); + let mut out = Vec::new(); + let mut a = i64::from(a0); + while a < i64::from(a1) { + let k = (a - i64::from(lo)).div_euclid(period) * period; + let end = (i64::from(a1) - k).min(i64::from(hi)); + let start = a - k; + out.push((start as i32, a as i32, (end - start) as i32)); + a = end + k; + } + out +} + +/// `r` cut where it crosses the edges of `tile`: each part moved onto the tile, with the offset +/// that moves it back (`part.translate(dx, dy)` is that part of `r`). A rectangle bigger than the +/// tile gives parts that overlap on it. +pub fn pieces(r: Rect, tile: Rect) -> Vec<(Rect, (i32, i32))> { + if r.is_empty() || tile.is_empty() { + return Vec::new(); + } + let xs = runs(r.x0, r.x1, tile.x0, tile.x1); + runs(r.y0, r.y1, tile.y0, tile.y1) + .into_iter() + .flat_map(|(ty, uy, h)| xs.iter().map(move |&(tx, ux, w)| (Rect::new(tx, ty, tx + w, ty + h), (ux - tx, uy - ty)))) + .collect() +} + +/// The values over `r` of an image repeating `tile` apart, given `read`, which returns a +/// rectangle of the tile (`n` values per pixel, row-major). A rectangle crossing at most one edge +/// per axis is read in its parts; a bigger one reads the tile area it covers once. +pub fn read(r: Rect, tile: Rect, n: usize, mut read: impl FnMut(Rect) -> Vec) -> Vec { + let w = r.width() as usize; + let mut out = vec![T::default(); w * r.height() as usize * n]; + if r.is_empty() || tile.is_empty() { + return out; + } + let (xs, ys) = (runs(r.x0, r.x1, tile.x0, tile.x1), runs(r.y0, r.y1, tile.y0, tile.y1)); + let mut copy = |src: &[T], at: usize, x: i32, y: i32, len: i32| { + let o = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; + let len = len as usize * n; + if let (Some(d), Some(s)) = (out.get_mut(o..o + len), src.get(at..at + len)) { + d.copy_from_slice(s); + } + }; + if xs.len() * ys.len() <= 4 { + for &(ty, uy, h) in &ys { + for &(tx, ux, len) in &xs { + let part = read(Rect::new(tx, ty, tx + len, ty + h)); + for row in 0..h { + copy(&part, row as usize * len as usize * n, ux, uy + row, len); + } + } + } + return out; + } + let area = fold_area(r, tile); + let src = read(area); + let aw = area.width() as usize; + for &(ty, uy, h) in &ys { + for row in 0..h { + for &(tx, ux, len) in &xs { + copy(&src, ((ty + row - area.y0) as usize * aw + (tx - area.x0) as usize) * n, ux, uy + row, len); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinates_wrap_onto_the_tile_from_any_distance() { + assert_eq!([-17, -1, 0, 15, 16, 33, 1_000_000].map(|v| wrap_coord(v, 0, 16)), [15, 15, 0, 15, 0, 1, 0]); + assert_eq!(wrap_coord(-3, 10, 20), 17); + } + + #[test] + fn pieces_cover_the_rectangle_once_and_land_on_the_tile() { + let tile = Rect::new(0, 0, 10, 8); + for r in [Rect::new(7, 6, 13, 11), Rect::new(-25, 3, 4, 5), Rect::new(2, 2, 5, 5), Rect::new(-3, -3, 30, 20)] { + let mut seen = std::collections::HashMap::new(); + for (part, (dx, dy)) in pieces(r, tile) { + assert!(tile.contains(part.x0, part.y0) && part.x1 <= tile.x1 && part.y1 <= tile.y1, "{part:?} on the tile"); + for y in part.y0..part.y1 { + for x in part.x0..part.x1 { + *seen.entry((x + dx, y + dy)).or_insert(0) += 1; + assert_eq!((wrap_coord(x + dx, 0, 10), wrap_coord(y + dy, 0, 8)), (x, y)); + } + } + } + assert_eq!(seen.len(), (r.width() * r.height()) as usize, "{r:?}"); + assert!(seen.values().all(|&c| c == 1) && seen.keys().all(|&(x, y)| r.contains(x, y)), "{r:?}"); + } + } + + #[test] + fn a_read_repeats_the_tile_whatever_the_rectangle() { + let tile = Rect::new(4, 2, 14, 10); + let value = |x: i32, y: i32| (wrap_coord(x, 4, 14) * 100 + wrap_coord(y, 2, 10)) as f32; + let mut reads = 0; + // Two values per pixel; `read` only ever asks for rectangles of the tile. + let mut from_tile = |q: Rect| { + reads += 1; + assert!(q.x0 >= tile.x0 && q.y0 >= tile.y0 && q.x1 <= tile.x1 && q.y1 <= tile.y1, "{q:?}"); + (q.y0..q.y1).flat_map(|y| (q.x0..q.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect::>() + }; + for r in [Rect::new(10, 7, 17, 12), Rect::new(5, 3, 9, 6), Rect::new(-31, -5, 40, 30), Rect::new(0, 0, 0, 5)] { + let got = read(r, tile, 2, &mut from_tile); + let want: Vec = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).flat_map(move |x| [value(x, y), -value(x, y)])).collect(); + assert_eq!(got, want, "{r:?}"); + } + assert!(reads <= 4 + 1 + 1, "small rectangles read in parts, big ones once ({reads})"); + } + + #[test] + fn the_fold_area_is_where_a_rectangle_lands() { + let tile = Rect::new(0, 0, 10, 8); + assert_eq!(fold_area(Rect::new(12, 9, 15, 12), tile), Rect::new(2, 1, 5, 4)); + assert_eq!(fold_area(Rect::new(8, 2, 12, 4), tile), Rect::new(0, 2, 10, 4)); + assert_eq!(fold_area(Rect::new(-50, -50, 50, 50), tile), tile); + assert_eq!(fold_area(Rect::EMPTY, tile), Rect::EMPTY); + assert!(fits(Rect::new(3, 3, 13, 11), tile) && !fits(Rect::new(0, 0, 11, 2), tile)); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7a..83614c9244a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -700,6 +700,13 @@ fn stroke_params(app: &PhotocraftApp, d: &Drag, points: &[Vec]) -> serde_js p } +/// View › Pattern Preview: a painting stroke goes on across the canvas edges (#8883). +pub(crate) fn pattern_wrap(app: &PhotocraftApp, p: &mut serde_json::Value) { + if app.ui.view.pattern_preview { + p["wrap"] = json!(true); + } +} + /// Tools whose strokes the engine renders while they are drawn (`LiveStroke`). pub(crate) fn strokes_live(tool: Tool) -> bool { matches!( @@ -881,6 +888,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? }; + pattern_wrap(app, &mut p); p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs index f92c3427e9f..c13dca17f60 100644 --- a/crates/ui-egui/src/eraser_ui.rs +++ b/crates/ui-egui/src/eraser_ui.rs @@ -49,14 +49,16 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) - /// the commit, with the jitter seed from the first point (`retouch_ui::stroke_seed`). pub(crate) fn background_eraser_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { let o = &app.ui.tool_options; - json!({ + let mut p = json!({ "points": points, "sampling": o.bg_sampling, "limits": o.bg_limits, "tolerance": o.bg_tolerance, "protectForegroundColor": o.bg_protect_fg, "seed": crate::retouch_ui::stroke_seed(points), - }) + }); + crate::canvas::pattern_wrap(app, &mut p); + p } /// Whether `tool` is the Eraser in Pencil mode: it erases hard, aliased pixels through diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 5ed59869de1..e62371bef59 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -32,6 +32,8 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); + } else { + crate::canvas::pattern_wrap(app, &mut p); } // The Clone Source panel's active slot (set by ⌥-click) drives the stroke: the engine keeps // the aligned pairing and applies the slot's scale/rotation/flip. @@ -49,7 +51,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { /// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { let o = &app.ui.tool_options; - Some(match tool { + let (cmd, mut p) = match tool { Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), @@ -59,7 +61,9 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st ), Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), _ => return None, - }) + }; + crate::canvas::pattern_wrap(app, &mut p); + Some((cmd, p)) } /// `paint.colorReplacement` params from the options bar (without `points`): shared by the live @@ -67,7 +71,7 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st /// stroke paints the same colour; Background Swatch sampling matches the background colour. pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { let o = &app.ui.tool_options; - json!({ + let mut p = json!({ "color": app.session.tools.foreground, "mode": o.cr_mode, "sampling": o.cr_sampling, @@ -76,7 +80,9 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { "antiAlias": o.cr_anti_alias, // A mask or channel target is refused: the tool recolours layer pixels only. "target": crate::canvas::paint_target(app), - }) + }); + crate::canvas::pattern_wrap(app, &mut p); + p } /// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and @@ -99,6 +105,7 @@ pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { p["pattern"] = json!(id); } + crate::canvas::pattern_wrap(app, &mut p); p } @@ -158,6 +165,10 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m _ => return false, }; p["points"] = pts; + // Spot Healing, Mixer Brush and History Brush; the others' params have it already. + if tool != Tool::Remove { + crate::canvas::pattern_wrap(app, &mut p); + } // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. p["target"] = crate::canvas::paint_target(app); let result = app.run(cmd, p); @@ -730,6 +741,43 @@ mod tests { } } + #[test] + fn every_retouching_stroke_wraps_in_pattern_preview() { + // View › Pattern Preview (#8883): a stroke across the right edge asks its command to go + // on at the opposite one, and only then. + let tools = [ + (Tool::Blur, "paint.blur"), + (Tool::Sharpen, "paint.sharpen"), + (Tool::Smudge, "paint.smudge"), + (Tool::Dodge, "paint.dodge"), + (Tool::Burn, "paint.burn"), + (Tool::Sponge, "paint.sponge"), + (Tool::MixerBrush, "paint.mixerBrush"), + (Tool::ColorReplacement, "paint.colorReplacement"), + (Tool::PatternStamp, "paint.patternStamp"), + (Tool::Healing, "paint.healingBrush"), + (Tool::HistoryBrush, "paint.historyBrush"), + (Tool::SpotHealing, "paint.spotHealing"), + (Tool::BackgroundEraser, "paint.backgroundEraser"), + ]; + for (tool, cmd) in tools { + for pattern_preview in [false, true] { + let mut app = app(); + stripes(&mut app, 8, "pixels"); + app.ui.view.pattern_preview = pattern_preview; + app.ui.clone_source = Some([20.0, 30.0]); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 86.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 112.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 112.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().unwrap_or_else(|| panic!("{tool:?}: no {cmd}")); + assert_eq!(p.get("wrap").and_then(Value::as_bool).unwrap_or(false), pattern_preview, "{tool:?}: {p}"); + } + } + } + #[test] fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { for pattern_preview in [false, true] { diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index ac88a6c899f..cd50206d7d9 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -500,6 +500,60 @@ fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() { } } +/// View › Pattern Preview (#8883): a Blur, Smudge or Dodge stroke dragged past the right edge +/// carries on at the left one, and the release still commits the pixels its live stroke drew. +#[test] +fn a_sequential_dab_stroke_past_the_edge_in_pattern_preview_commits_its_live_pixels() { + for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}})); + run("paint.stroke", json!({"points": [[0, 50], [200, 50]], "size": 30, "hardness": 0.5, "color": "#c04020"})); + run("paint.stroke", json!({"points": [[0, 35], [200, 65]], "size": 8, "hardness": 1.0, "color": "#2060c0"})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + let before = h.state().session.active().unwrap().doc.clone(); + { + let app = h.state_mut(); + app.ui.tool = tool; + app.ui.view.pattern_preview = true; + app.ui.views[0].zoom = 2.0; + app.ui.views[0].center = [100.0, 50.0]; + } + h.run_steps(1); + move_to(&mut h, 150.0, 50.0); + button(&mut h, 150.0, 50.0, true); + for (x, y) in [(175.0, 52.0), (200.0, 50.0), (225.0, 47.0), (245.0, 50.0)] { + move_to(&mut h, x, y); + } + assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke"); + h.state_mut().retouch_from_live = false; + button(&mut h, 245.0, 50.0, false); + h.run_steps(2); + let app = h.state(); + assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again"); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke"); + assert_eq!(p["wrap"], json!(true), "{cmd}: {p}"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + fresh.execute(cmd, p).unwrap(); + let st = app.session.active().unwrap(); + let (a, b, pre) = ( + photocraft_compose::render(&st.doc, st.doc.bounds()), + photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()), + photocraft_compose::render(&before, st.doc.bounds()), + ); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ"); + let left = |buf: &photocraft_compose::Buffer| (30..70).flat_map(|y| (0..30).map(move |x| (x, y))).map(|(x, y)| buf.px[y * 200 + x]).collect::>(); + assert_ne!(left(&a), left(&pre), "{cmd}: the stroke came back at the left edge"); + } +} + /// Pressure changes Smudge's painted footprint, but not the square of colour it carries. /// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo. #[test] From 5c5a6888a9f3f0f6d82e3c1e3e64213ecc72d67b Mon Sep 17 00:00:00 2001 From: FartmanAndBurps <345790579+FartmanAndBurps@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:19:04 +0000 Subject: [PATCH 035/384] Keep Work Path drag focus reconciliation on the primary canvas Secondary document views must not cancel a primary-canvas gesture. Cover secondary focus, genuine primary focus loss, and one-step move history. --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/work_path_drag_tests.rs | 57 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 36e63683d53..7d55d12c9e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3473,7 +3473,9 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); - crate::work_path_drag::reconcile(app, ctx.input(|i| i.focused)); + if primary { + crate::work_path_drag::reconcile(app, ctx.input(|i| i.focused)); + } if crate::work_path_drag::preview(app).is_some() { ctx.request_repaint(); } diff --git a/crates/ui-egui/src/work_path_drag_tests.rs b/crates/ui-egui/src/work_path_drag_tests.rs index 5bfbe482898..c18166c8420 100644 --- a/crates/ui-egui/src/work_path_drag_tests.rs +++ b/crates/ui-egui/src/work_path_drag_tests.rs @@ -254,3 +254,60 @@ fn view_mapping_fractional_offsets_and_transient_state_are_preserved() { assert_eq!(work(&app), before.transform(&Affine::translate(delta[0], delta[1]))); } } + +fn canvas_frame(app: &mut PhotocraftApp, primary: bool, focused: bool) { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let input = egui::RawInput { screen_rect: Some(rect), focused, ..Default::default() }; + let view = app.ui.views[0].clone(); + ctx.run_ui(input, |ui| { + crate::canvas::canvas_view(app, ui, 0, rect, view.clone(), primary); + }) + .textures_delta + .clear(); +} + +#[test] +fn secondary_canvas_focus_does_not_cancel_primary_drag_or_split_history() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [21.25, 24.5]); + assert_eq!(preview(&app), Some([11.25, 4.5])); + canvas_frame(&mut app, true, true); + assert_eq!(preview(&app), Some([11.25, 4.5])); + canvas_frame(&mut app, false, false); + assert_eq!(preview(&app), Some([11.25, 4.5]), "secondary view must not cancel the primary drag"); + assert!(app.drag.is_some()); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + moving(&mut app, [23.375, 26.125]); + canvas_frame(&mut app, false, false); + assert_eq!(preview(&app), Some([13.375, 6.125])); + up(&mut app, [23.375, 26.125]); + let after = before.transform(&Affine::translate(13.375, 6.125)); + assert_eq!(work(&app), after); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(work(&app), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(work(&app), after); +} + +#[test] +fn primary_canvas_focus_loss_cancels_drag_without_history_or_late_release() { + let mut app = app(); + let before = work(&app); + let history = app.session.active().unwrap().history.past_len(); + down(&mut app, [10.0, 20.0]); + moving(&mut app, [21.25, 24.5]); + assert!(preview(&app).is_some()); + canvas_frame(&mut app, true, false); + assert!(preview(&app).is_none()); + assert!(app.drag.is_none()); + assert!(finish(&mut app, [10.0, 20.0], [21.25, 24.5]).unwrap().is_err()); + assert_eq!(work(&app), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} From 6cbcd89f0c63d4afd46554d42647df322a9c670e Mon Sep 17 00:00:00 2001 From: Leel Wasanatha <260480244+kavishkashehan726-sudo@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:50:47 +0000 Subject: [PATCH 036/384] Pen: carry on a shape's or vector mask's open path, and show the continue pointer (#9356) A Pen press on an endpoint of an open path carried it on only for the Work Path or a saved path (#6593). In Shape mode the press started a new shape layer, and on a targeted vector mask a new subpath, so a pen shape could not be continued once drawing stopped. Now the selected shape layer's path (Shape mode) and the targeted vector mask resume like the Work Path, and the commit updates the same layer in one History step (shape.edit / layer.vectorMask.edit). Nothing showed that the endpoints can be clicked, either: the Pen now shows Photoshop's continue-path pointer (the cross with a slash) over an endpoint it would carry on from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NgLQVizswqgNnzBv1taRD --- crates/ui-egui/src/canvas.rs | 9 +++ crates/ui-egui/src/pen_variants_tests.rs | 62 ++++++++++++++++++++ crates/ui-egui/src/tool_cursor.rs | 10 ++++ crates/ui-egui/src/tool_cursor/tests.rs | 13 +++++ crates/ui-egui/src/vector_ui.rs | 72 ++++++++++++++++++------ 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ab3e6939a7a..b0fd9e5c48c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4835,6 +4835,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { crate::tool_cursor::close_path(&painter, p) } + // Over an endpoint of an open path, the continue-path pointer (#9356). + Tool::Pen + if { + let [x, y] = xf.to_doc(p); + crate::vector_ui::continues_path_at(app, tool, x, y) + } => + { + crate::tool_cursor::continue_path(&painter, p) + } // The pen tools: their own icon with its nib on the point, or the crosshair for // precise Other Cursors (#4639). Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => { diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index cee8e37bbd1..54037c40fa4 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -273,3 +273,65 @@ fn undo_in_a_resumed_draft_stops_at_the_paths_own_anchors() { assert!(app.ui.direct_selection.anchors.is_empty()); assert_eq!(work_path(&app), before, "the path is as it was"); } + +/// #9356: in Shape mode a Pen press on an endpoint of the selected shape layer's open path carries +/// that path on, from either end, and the commit updates the same layer in one History step +/// instead of making a new shape layer. Elsewhere the press starts a new shape as before. +#[test] +fn shape_mode_carries_on_the_selected_shapes_open_path() { + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + let layers = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers.len(); + let shape = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Shape(sh) => { + sh.path.subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect::>()).collect::>() + } + other => panic!("not a shape layer: {other:?}"), + } + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + let (n, before) = (layers(&app), steps(&app)); + assert!(crate::vector_ui::continues_path_at(&app, Tool::Pen, 81.0, 21.0), "the continue pointer over the endpoint"); + assert!(!crate::vector_ui::continues_path_at(&app, Tool::Pen, 50.0, 20.0), "not mid-segment"); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n, "no new shape layer"); + assert_eq!(steps(&app), before + 1, "one History step"); + assert_eq!(shape(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]]); + // From the first anchor, reversed. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(shape(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]); + // Anywhere else: a new shape layer. + click(&mut app, 150.0, 150.0); + click(&mut app, 200.0, 150.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(layers(&app), n + 1); +} + +/// #9356: with a vector mask targeted, the Pen carries on the mask's open subpath in place. +#[test] +fn a_targeted_vector_mask_carries_on_its_open_path() { + let mut app = app(Tool::Pen); + let id = app.session.active().unwrap().active_layer.unwrap(); + let open = json!({"subpaths": [{"closed": false, "knots": [{"anchor": [20.0, 20.0]}, {"anchor": [80.0, 20.0]}]}]}); + app.run("layer.vectorMask.add", json!({"layer": id.0, "path": open})).unwrap(); + app.ui.vector_mask_target = true; + let mask = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + let p = &st.doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path; + p.subpaths.iter().map(|s| s.knots.len()).collect::>() + }; + assert_eq!(mask(&app), [2]); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(mask(&app), [3], "the same subpath, extended"); +} diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 5062c839da7..28e4d45f95b 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -88,6 +88,16 @@ pub(crate) fn close_path(painter: &Painter, at: Pos2) -> CursorIcon { outline(painter, at, vec![close_ring()], true) } +/// The Pen's pointer over an endpoint a click would carry the path on from (#9356): the centre +/// cross with a short slash below right, as Photoshop's pen shows one there. +pub(crate) fn continue_path(painter: &Painter, at: Pos2) -> CursorIcon { + outline(painter, at, vec![continue_slash()], true) +} + +fn continue_slash() -> Vec { + [(6.5, 12.5), (11.5, 5.5), (12.5, 6.5), (7.5, 13.5)].into_iter().map(|(x, y)| vec2(x, y)).collect() +} + fn close_ring() -> Vec { (0..16).map(|i| (i as f32 / 16.0 * std::f32::consts::TAU, vec2(9.0, 9.0))).map(|(a, c)| c + vec2(a.cos(), a.sin()) * 3.5).collect() } diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index a57e7f0ae2f..fa241f6e5c8 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -599,3 +599,16 @@ fn type_path_cursors_are_small_and_centred() { } } } + +/// #9356: the continue-path pointer is the centre cross with a slash below right. +#[test] +fn continue_path_bitmap_has_the_centre_cross_and_a_slash_beside_it() { + for scale in [1.0, 2.0] { + let image = rasterize(&Shape::Outline { rings: vec![continue_slash()].into(), centre: true }, scale).unwrap(); + let [x, y] = image.hotspot; + let at = |dx: f32, dy: f32| alpha(&image, x + (dx * scale) as u16, y + (dy * scale) as u16); + assert!(at(0.0, 0.0) > 230, "the centre cross"); + assert!(at(9.5, 9.5) > 0, "the slash"); + assert_eq!(at(0.0, 9.0), 0, "apart from the cross"); + } +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 81426ccb5e8..ce63a26dca2 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -52,6 +52,10 @@ pub struct Resumed { pub path: String, pub index: usize, pub was: photocraft_doc::vector::Subpath, + /// The layer whose shape path (Shape mode) or targeted vector mask the subpath is in (#9356); + /// `None` for the Paths panel's path `path`. + #[serde(default)] + pub layer: Option, } impl PenPath { @@ -237,6 +241,12 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { } } +/// Would a Pen click at `(x, y)` carry on an open path from its endpoint there (#6593)? The canvas +/// shows Photoshop's continue-path pointer there (#9356), as the endpoints look like any anchor. +pub fn continues_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { + tool == Tool::Pen && app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) && resume_at(app, x, y, 6.0 / app.point_zoom().max(0.01) as f64).is_some() +} + /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. @@ -546,7 +556,12 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { return; } let path = pen_to_json(pen, closed); - let r = if app.ui.tool_options.vector_mode == "shape" { + let resumed = pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r, p))); + let r = if let Some((Resumed { layer: Some(id), .. }, p)) = &resumed { + // A shape layer's path or a vector mask carried on (#9356): the same layer, one step. + let shape = app.session.active().and_then(|st| st.doc.layer(photocraft_doc::LayerId(*id))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + app.run(if shape { "shape.edit" } else { "layer.vectorMask.edit" }, json!({"layer": id, "path": p})) + } else if app.ui.tool_options.vector_mode == "shape" { let fill = if closed { fill_param(app) } else { Value::Null }; let stroke = if closed { stroke_param(app) @@ -557,10 +572,9 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { stroke }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) - } else if let Some((name, p)) = - pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r.path.clone(), p))) - { + } else if let Some((r, p)) = resumed { // The resumed subpath, carried on, takes its old place: one step, no second subpath. + let name = r.path.clone(); app.run("path.set", json!({"name": name, "path": p})).inspect(|_| app.ui.selected_path = Some(name)) } else if let Some((id, existing)) = targeted_vector_mask(app) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. @@ -576,7 +590,8 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // A resumed draft whose subpath changed meanwhile joins the path it came from, as a new // subpath, even with nothing selected in the Paths panel. let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); - let resumed_from = pen.resumes.as_ref().filter(|r| app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); + let resumed_from = + pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); @@ -601,19 +616,44 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { if name == "work" { doc.work_path.as_ref() } else { doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) } } -/// The open subpath of the path a new Pen path would join (the one selected in the Paths panel, -/// else the work path) that has its first or last anchor at `(x, y)`, as a Pen path to carry on -/// drawing from there (#6593). Its knots run towards that anchor: reversed, handles swapped, when -/// it is the first. Shape mode and a targeted vector mask take new paths, so they resume nothing. -fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { - if app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some() { - return None; +/// The path of `layer` that the Pen draws into: a shape layer's own path, else its vector mask. +fn layer_path(doc: &Document, layer: u64) -> Option<&Path> { + let l = doc.layer(photocraft_doc::LayerId(layer))?; + match &l.content { + LayerContent::Shape(sh) => Some(&sh.path), + _ => l.vector_mask.as_ref().map(|m| &m.path), } +} + +/// The path a resumed subpath is in, if it is still in `r`'s document. +fn resumed_path<'a>(doc: &'a Document, r: &Resumed) -> Option<&'a Path> { + (doc.id.0 == r.doc).then_some(())?; + match r.layer { + Some(id) => layer_path(doc, id), + None => doc_path(doc, &r.path), + } +} + +/// The open subpath of the path a new Pen path would go into that has its first or last anchor at +/// `(x, y)`, as a Pen path to carry on drawing from there (#6593). That path is the selected shape +/// layer's in Shape mode, the targeted vector mask's (#9356), else the one selected in the Paths +/// panel or the work path. Its knots run towards that anchor: reversed, handles swapped, when it is +/// the first. +fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { let st = app.session.active()?; let doc = st.doc.id.0; - let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let (name, layer, path) = if app.ui.tool_options.vector_mode == "shape" { + let (id, _) = active_shape_path(app)?; + ("layer".to_string(), Some(id), layer_path(&st.doc, id)?) + } else if let Some((id, _)) = targeted_vector_mask(app) { + ("layer".to_string(), Some(id), layer_path(&st.doc, id)?) + } else { + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let path = doc_path(&st.doc, &name)?; + (name, None, path) + }; let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; - doc_path(&st.doc, &name)?.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { + path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { let reversed = if s.knots.last().is_some_and(near) { false } else if s.knots.first().is_some_and(near) { @@ -629,7 +669,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { } } let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone() }; + let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone(), layer }; Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) }) } @@ -638,7 +678,7 @@ fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { /// anchors and closing, which keeps its other settings (its path operation). `None` when the /// subpath is no longer the one drawing resumed (another document, or the path changed). fn with_resumed_subpath(doc: &Document, r: &Resumed, drawn: &Value) -> Option { - let path = doc_path(doc, &r.path).filter(|_| doc.id.0 == r.doc)?; + let path = resumed_path(doc, r)?; if path.subpaths.get(r.index) != Some(&r.was) { return None; } From 26c218f722746e174b8fabe8e0eea4dfce6a64f0 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 11:36:15 -0200 Subject: [PATCH 037/384] retouch: wrapped Dodge and Burn tone each pixel once; Spot Healing skips a window with nothing to heal from Dodge and Burn remember each pixel's coverage so one stroke tones it once. With wrap on, that memory was keyed by the unwrapped position. A stroke longer than the canvas came back to a pixel under a new key and toned it again from its toned value: mid grey went to 0.816 instead of 0.651 under Dodge at 60%. Coverage is now kept per canvas pixel. A dab bigger than the canvas is folded first, and each pixel takes the most coverage any of its copies gets (the copy WorkTiles keeps). Spot Healing returns early when the stroke covers its whole sampling window, as a wrapped dab bigger than the canvas does. The fill had nothing to sample from. It left the pixels alone but reported the whole canvas as damage. Wrap off, Dodge in bench_live_dab_release (6000x4000, 8-bit, medians of 4 interleaved runs) stays within 0.8% of before at every brush size. --- crates/engine/src/retouch_cmds.rs | 75 +++++++++++++++++++------ crates/engine/src/retouch_cmds/tests.rs | 45 +++++++++++++++ 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 4480f9b8b87..b2eb58059f1 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -16,7 +16,7 @@ use photocraft_color::{BlendMode, PixelFormat, SampleType}; use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_paint::retouch::{Footprint, Region, Smudge, WorkTiles, alpha_index, apply_coverage, apply_dab_stroke_wrapped}; -use photocraft_paint::wrap::wrap_coord; +use photocraft_paint::wrap::{fits, fold_area, pieces, wrap_coord}; use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use rayon::prelude::*; @@ -875,7 +875,8 @@ fn spot_heal_surface( } } let hole: Vec = stroke_cov.iter().map(|c| *c > 0.0).collect(); - if !hole.iter().any(|h| *h) { + // Nothing to heal, or (a wrapped dab covering its whole window) nothing to heal from. + if !hole.iter().any(|h| *h) || hole.iter().all(|h| *h) { return Ok(Rect::EMPTY); } // Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels @@ -971,8 +972,9 @@ fn color_dab(fmt: &PixelFormat, work: &mut Region, fp: &Footprint, strength: f32 /// stroke's coverage there (`toned` is the full-exposure colour). Dabs build up coverage as in a /// brush stroke, capped at full coverage, so Exposure caps a stroke the way Opacity caps a paint /// stroke: overlapping dabs and passes within one stroke don't compound the tone curve, and a -/// new stroke builds on the last. -fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { +/// new stroke builds on the last. Wrapped around `wrap` (View › Pattern Preview), the coverage is +/// kept per canvas pixel, so a stroke coming back to a pixel one canvas away tones it once. +fn tone_stroke(fmt: PixelFormat, wrap: Option, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect { /// Side of the cells that remember, per touched pixel, the coverage applied so far and the /// original pixel (copied from the working pixels the first time a dab reaches it). Dense /// cells rather than a map per pixel: a map lookup per covered pixel made long strokes with @@ -985,11 +987,8 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let mut cells: std::collections::HashMap<(i32, i32), Cell> = std::collections::HashMap::new(); let a = alpha_index(&fmt); let n = fmt.channels(); - Box::new(move |work, fp| { - let r = fp.rect.intersect(&work.rect); - if r.is_empty() { - return; - } + // Tone the pixels of `px` at `r` moved by `(dx, dy)`, remembered at `r`. + let mut tone = move |px: &mut Region, fp: &Footprint, r: Rect, (dx, dy): (i32, i32)| { let mut enc = [0.0f32; 8]; for cy in r.y0.div_euclid(CELL)..=(r.y1 - 1).div_euclid(CELL) { for cx in r.x0.div_euclid(CELL)..=(r.x1 - 1).div_euclid(CELL) { @@ -1003,7 +1002,7 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let cell = cells.entry((cx, cy)).or_insert_with(|| Cell { cov: vec![-1.0; len], orig: vec![0.0; len * n] }); for y in part.y0..part.y1 { for x in part.x0..part.x1 { - let k = fp.at(x, y).clamp(0.0, 1.0); + let k = fp.at(x + dx, y + dy).clamp(0.0, 1.0); if k <= 0.0 { continue; } @@ -1011,7 +1010,7 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let (Some(prev), Some(src)) = (cell.cov.get_mut(i), cell.orig.get_mut(i * n..i * n + n)) else { continue }; if *prev < 0.0 { // First touch: the working pixel is still the original. - src.copy_from_slice(work.px(x, y)); + src.copy_from_slice(px.px(x + dx, y + dy)); *prev = 0.0; } // Dabs build up the stroke's coverage as a brush stroke's do (flow), up @@ -1025,16 +1024,55 @@ fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) let t = toned([o[0], o[1], o[2]]); let m = [o[0] + (t[0] - o[0]) * k, o[1] + (t[1] - o[1]) * k, o[2] + (t[2] - o[2]) * k]; from_rgba_into(&fmt, [m[0], m[1], m[2], o[3]], &mut enc); - let px = work.px_mut(x, y); + let out = px.px_mut(x + dx, y + dy); for c in 0..n { if Some(c) != a { - px[c] = enc[c]; + out[c] = enc[c]; } } } } } } + }; + Box::new(move |work, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } + match wrap { + None => tone(work, fp, r, (0, 0)), + Some(tile) if fits(r, tile) => { + for (part, off) in pieces(r, tile) { + tone(work, fp, part, off); + } + } + // A dab bigger than the canvas covers its pixels more than once: tone each at the + // most coverage any copy gets (the copy `WorkTiles` keeps), and give every copy that. + Some(tile) => { + let area = fold_area(r, tile); + let mut on = Region::new(area, work.ch); + let mut most = Footprint { dab: fp.dab, index: fp.index, rect: area, cov: vec![0.0; area.width() as usize * area.height() as usize] }; + let at = |x, y| (wrap_coord(x, tile.x0, tile.x1), wrap_coord(y, tile.y0, tile.y1)); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (tx, ty) = at(x, y); + let i = on.index(tx, ty); + on.px_mut(tx, ty).copy_from_slice(work.px(x, y)); + if let Some(m) = most.cov.get_mut(i) { + *m = m.max(fp.at(x, y)); + } + } + } + tone(&mut on, &most, area, (0, 0)); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (tx, ty) = at(x, y); + work.px_mut(x, y).copy_from_slice(on.px(tx, ty)); + } + } + } + } }) } @@ -1140,11 +1178,12 @@ impl DabTool { } } - /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. - fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`, on a stroke + /// wrapped around `wrap`. + fn effect(&self, fmt: PixelFormat, spacing: f32, wrap: Option) -> DabEffect { let sp = spacing; match self.clone() { - Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)), + Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, wrap, move |c| dodge_burn(c, exposure, range, burn, protect)), // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))), Self::Focus { sharpen, strength, protect, sigma } => { @@ -1206,8 +1245,8 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(l.bounds); } let fmt = surf.format(); - let mut effect = tool.effect(fmt, stroke.brush.spacing); let (halo, wrap) = (tool.neighborhood().halo(), wrap_tile(pre, p)); + let mut effect = tool.effect(fmt, stroke.brush.spacing, wrap); let mut sampler = all.then(|| AllLayers::new(pre, fmt).wrapped(wrap, &stroke, halo)); Ok(apply_dab_stroke_wrapped(surf, &stroke, sel, lock, halo, wrap, |work, fp| match &mut sampler { Some(all) => all.dab(work, fp, |w, f| effect(w, f)), @@ -1274,7 +1313,7 @@ impl LiveDab { let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone())); let effect = tool.clone(); - let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, _| effect.effect(pre.format(), spacing))?; + let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre, wrap| effect.effect(pre.format(), spacing, wrap))?; live.tool = Some((tool, brush)); Ok(live) } diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index e88129cb67c..cbe535097b8 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -2603,6 +2603,51 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { } } +#[test] +fn spot_healing_with_nothing_left_to_sample_leaves_the_layer_alone() { + // Wrapped, the dab covers all of a canvas smaller than it, so its window has no pixel left + // to heal from. + for depth in DEPTHS { + for kind in ["contentAware", "createTexture", "proximityMatch"] { + let mut s = session(48, 36, depth, "rgb"); + textured(&mut s); + let before = layer_pixels(&s); + let r = s.execute("paint.spotHealing", json!({"points": [[48, 36]], "size": 64, "hardness": 100, "type": kind, "wrap": true})).unwrap(); + assert_eq!(layer_pixels(&s), before, "{kind} at {depth}-bit"); + assert_eq!(r["damage"][2].as_i64().unwrap() * r["damage"][3].as_i64().unwrap(), 0, "{kind} at {depth}-bit: no damage, got {}", r["damage"]); + } + } +} + +#[test] +fn a_wrapped_dodge_or_burn_stroke_tones_each_pixel_once() { + // A stroke round the canvas, or a dab bigger than it, comes back to pixels one canvas away; + // the stroke still tones each of them once, as a single dab does. + for depth in DEPTHS { + for cmd in ["paint.dodge", "paint.burn"] { + let run = |w: u32, h: u32, mut p: Value| { + let mut s = session(w, h, depth, "rgb"); + paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]); + p["exposure"] = json!(60); + s.execute(cmd, p).unwrap(); + layer_pixels(&s) + }; + let once = run(WRAP.0, WRAP.1, json!({"points": [[24, 18]], "size": 10, "hardness": 100})); + let n = once.len() / (WRAP.0 * WRAP.1) as usize; + let toned = &once[(18 * WRAP.0 as usize + 24) * n..][..n]; + assert_ne!(toned, &once[..n], "{cmd} at {depth}-bit: the dab tones"); + let round = run(WRAP.0, WRAP.1, json!({"points": [[2, 18], [122, 18]], "size": 10, "hardness": 100, "wrap": true})); + for x in 0..WRAP.0 as usize { + assert_eq!(&round[(18 * WRAP.0 as usize + x) * n..][..n], toned, "{cmd} at {depth}-bit: ({x}, 18)"); + } + let big = run(16, 12, json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 100, "wrap": true})); + for (i, px) in big.chunks(n).enumerate() { + assert_eq!(px, toned, "{cmd} at {depth}-bit: pixel {i} under the big dab"); + } + } + } +} + #[test] fn wrapped_live_strokes_show_what_their_commits_paint() { // Across the left and bottom edges, as the canvas draws them: Dodge … Smudge commit the From 242d5d23a11ff30e10702764b865655a9df89b4b Mon Sep 17 00:00:00 2001 From: GyoJoong Kum Date: Mon, 26 Oct 2026 02:02:20 +0000 Subject: [PATCH 038/384] fix(ui): merge initial tool color saves against default swatches --- crates/ui-egui/src/prefs_ui.rs | 41 +++++++++++++++++++++++++++++-- crates/ui-egui/src/tool_memory.rs | 26 +++++++++++++------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 23aca76e642..bb44c23089a 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -622,8 +622,16 @@ fn write_prefs(app: &mut PhotocraftApp, ours: &Value) -> Result<(), String> { let stored = app.services.load_prefs.as_mut().and_then(|load| load()).and_then(|t| serde_json::from_str::(&t).ok()); let text = match (stored, &app.prefs_rt.saved_value) { (Some(mut theirs @ Value::Object(_)), Some(base)) => { - let base = &without_stale_migration(base, &theirs); - merge_changes(base, ours, &mut theirs); + let mut base = without_stale_migration(base, &theirs); + // Older files have no toolColors object. Diff the first saved swatches against + // their actual defaults so an unchanged field cannot overwrite another window's edit. + if matches!(base.get("toolColors"), None | Some(Value::Null)) + && ours.get("toolColors").is_some_and(Value::is_object) + && let Some(fields) = base.as_object_mut() + { + fields.insert("toolColors".into(), crate::tool_memory::default_colors()); + } + merge_changes(&base, ours, &mut theirs); serde_json::to_string_pretty(&theirs) } _ => serde_json::to_string_pretty(ours), @@ -3734,6 +3742,35 @@ mod tests { assert!(app3.ui.brush_picker_list.show_name && app3.ui.brush_picker_list.show_stroke && app3.ui.brush_picker_list.show_tip); } + #[test] + fn tool_colors_first_saves_keep_other_instances_separate_edits() { + for initial in [ + None, + Some("{}".into()), + Some(json!({"toolColors": null}).to_string()), + Some(json!({"toolColors": crate::tool_memory::default_colors()}).to_string()), + ] { + for background_first in [false, true] { + let store = Arc::new(Mutex::new(initial.clone())); + let (mut foreground, mut background) = (app_on(&store, Arc::default()), app_on(&store, Arc::default())); + let fg = [0.25, 0.5, 0.75, 1.0]; + let bg = [0.75, 0.5, 0.25, 1.0]; + foreground.run("tools.setColors", json!({"foreground": fg})).unwrap(); + background.run("tools.setColors", json!({"background": bg})).unwrap(); + let (first, second) = if background_first { (&mut background, &mut foreground) } else { (&mut foreground, &mut background) }; + crate::tool_memory::save_on_exit(first).unwrap(); + crate::tool_memory::save_on_exit(second).unwrap(); + // Further saves must still keep the other window's independent field. + save_preferences(first).unwrap(); + save_preferences(second).unwrap(); + let restored = app_on(&store, Arc::default()); + assert_eq!(restored.session.tools.foreground, fg); + assert_eq!(restored.session.tools.background, bg); + assert_eq!(restored.session.tools.other_colors, photocraft_engine::ToolState::MASK_COLORS); + } + } + } + #[test] fn tool_colors_survive_restart_before_the_first_frame() { let (mut app, store) = app_with_store(); diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 1ce88605dc7..433765c01f4 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -97,19 +97,27 @@ fn persist_colors(app: &mut PhotocraftApp, ctx: &egui::Context) { } app.prefs_rt.tool_colors = Some(now); app.prefs_rt.tool_cmyk_picks = app.session.tools.cmyk_picks.clone(); - let cmyk_picks = &app.prefs_rt.tool_cmyk_picks; - let [[foreground, background], [mask_foreground, mask_background]] = now; - app.session.prefs.edit(|p| { - p.tool_colors = json!({ - "foreground": foreground, "background": background, - "maskForeground": mask_foreground, "maskBackground": mask_background, - "cmykPicks": cmyk_picks, - }); - }); + let saved = color_value(now, &app.prefs_rt.tool_cmyk_picks); + app.session.prefs.edit(|p| p.tool_colors = saved); // Panels and shortcuts can change colours after prefs_ui::tick already ran this frame. ctx.request_repaint(); } +fn color_value(pairs: [[[f32; 4]; 2]; 2], cmyk_picks: &CmykPicks) -> Value { + let [[foreground, background], [mask_foreground, mask_background]] = pairs; + json!({ + "foreground": foreground, "background": background, + "maskForeground": mask_foreground, "maskBackground": mask_background, + "cmykPicks": cmyk_picks, + }) +} + +/// A missing legacy value represents these defaults, not an indivisible null leaf. +pub(crate) fn default_colors() -> Value { + let tools = photocraft_engine::ToolState::default(); + color_value([[tools.foreground, tools.background], tools.other_colors], &tools.cmyk_picks) +} + fn restore_colors(app: &mut PhotocraftApp) { let saved = &app.session.prefs().tool_colors; let mut pairs = colors(app); From 9fe5d908bffe2f725f799491c9c56676fdccfc6b Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 27 Oct 2026 09:43:08 +0700 Subject: [PATCH 039/384] fix(psd): a Puppet Warp mesh is read only up to 65 536 vertices --- crates/io/src/puppet_map.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/io/src/puppet_map.rs b/crates/io/src/puppet_map.rs index c5581d34045..f1015af30b5 100644 --- a/crates/io/src/puppet_map.rs +++ b/crates/io/src/puppet_map.rs @@ -28,8 +28,9 @@ use crate::blocks::num; pub(super) const CLASS: &str = "rigidTransform"; pub(super) const COMMAND: &str = "layer.smartObjects.puppetWarp"; pub(super) const TEMPLATE_KEY: &str = "__puppetPsd"; -/// Far beyond the few hundred vertices Photoshop's meshes have. -const MAX_VERTS: usize = 1 << 20; +/// Far beyond the few hundred vertices Photoshop's meshes have, yet small enough that the +/// copies a hostile mesh makes (arrays, JSON params, cloned filters) stay bounded. +const MAX_VERTS: usize = 1 << 16; fn raw<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { match d.get(key)? { From a9b878834d171b571d0d171cd111f1df0b6e2f49 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:50:32 +0100 Subject: [PATCH 040/384] Photo Filter opens at its last settings (#9879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image › Adjustments › Photo Filter… now opens at the filter, colour, density and Preserve Luminosity of its last OK, as Photoshop's does, without holding ⌥. The other adjustment dialogs keep opening at their neutral settings (⌥ still reopens them at the last ones, #8900). `adjust_dialog::open` picks the last settings for the kinds listed in `REMEMBERS_LAST`; the old neutral behaviour is `open_neutral`, which the smart filter editor now uses so a smart filter's dialog always shows that filter's own settings. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 15 ++++++-- .../src/dialogs/adjust_dialog/tests.rs | 36 +++++++++++++++++++ crates/ui-egui/src/panels/smart_ui.rs | 3 +- docs/ui-parity.md | 4 ++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index e1d61ab6590..1ac70db208c 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -261,8 +261,19 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { + if command.strip_prefix(PREFIX).is_some_and(|kind| REMEMBERS_LAST.contains(&kind)) { open_last(app, command) } else { open_neutral(app, command) } +} + +/// Opens the dialog for `command` with the kind's neutral settings. +pub fn open_neutral(app: &mut PhotocraftApp, command: &str) -> Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; let mode = curve_mode(app, kind); @@ -283,7 +294,7 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { /// adjustment's shortcut, ⌥⌘L / ⌥⌘M, #8900), or at the neutral ones when there are none or they /// don't fit the current colour mode. pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { - let id = open(app, command)?; + let id = open_neutral(app, command)?; let kind = command.strip_prefix(PREFIX).unwrap_or_default(); let mode = curve_mode(app, kind); let Some(last) = app.session.prefs().dialogs.get(command).cloned() else { return Some(id) }; diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index d3bcf2b6886..e2ba88ca61d 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -849,3 +849,39 @@ fn curves_on_a_targeted_filter_mask_is_a_gray_plane() { assert!(h.ch[0][0] > 0 && h.ch[0][255] > 0, "black and white levels"); assert_eq!(curve_mode(&app, "curves"), photocraft_doc::ColorMode::Grayscale); } + +/// #9879: Photo Filter opens at the filter and density last confirmed in it, without ⌥; Levels +/// still opens at its neutral settings, and a smart filter's dialog never takes the last ones. +#[test] +fn photo_filter_opens_at_its_last_settings() { + let mut app = app_with_image(); + let cmd = "image.adjustments.photoFilter"; + let first = open_neutral(&mut app, cmd).unwrap(); + let neutral = crate::filter_dialog::params_of(&app.ui.dialog_mut(first).unwrap().fields.clone()); + app.ui.dialogs.clear(); + let id = open(&mut app, cmd).unwrap(); + let sample = photocraft_engine::adjust_params::to_params( + &photocraft_engine::adjust_params::from_params("photoFilter", &sample_params("photoFilter"), None, photocraft_doc::ColorMode::Rgb).unwrap(), + ); + assert_ne!(sample, neutral, "the sample differs from the neutral settings"); + if let Value::Object(p) = sample.clone() { + app.ui.dialog_mut(id).unwrap().fields.extend(p); + } + crate::dialogs::confirm(&mut app, id).unwrap(); + + let again = open(&mut app, cmd).unwrap(); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()), sample, "reopened at the last settings"); + app.ui.dialogs.clear(); + let smart = open_neutral(&mut app, cmd).unwrap(); + assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(smart).unwrap().fields.clone()), neutral, "open_neutral ignores them"); + app.ui.dialogs.clear(); + + let levels = "image.adjustments.levels"; + let id = open(&mut app, levels).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("inBlack".into(), json!(30)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let again = open(&mut app, levels).unwrap(); + let defaults = + photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap()); + assert_eq!(app.ui.dialog_mut(again).unwrap().fields["inBlack"], defaults["inBlack"], "Levels opens neutral without ⌥"); +} diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 8e531b47b63..c1b592be490 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -483,7 +483,8 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: return; } }; - let Some(id) = crate::adjust_dialog::open(app, &f.command).or_else(|| { + // A smart filter's dialog shows the filter's own settings, never the last-used ones. + let Some(id) = crate::adjust_dialog::open_neutral(app, &f.command).or_else(|| { let id = crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()?; // The filter dialog opens on the user's last-used values; a recorded filter that lacks a // key must show (and save) the schema default instead. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4490d693c27..660ad194486 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Image › Adjustments › Photo Filter… opens at the filter and density of its last OK, #9879; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; 12 of 181 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -208,6 +208,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | ⌥ + an adjustment's shortcut opens it at its last settings (⌥⌘L Levels, ⌥⌘M Curves, and so ⌥⌘U, ⌥⌘B) | done (#8900): the settings of the last OK, kept in the preferences; Cancel and smart filter edits keep nothing; a key assigned to another command stays that command's. ⌥ while choosing the item from Image › Adjustments does the same | `shortcut_dispatch/tests.rs` `alt_adjustment_shortcut_reopens_the_last_confirmed_settings`, `alt_choosing_an_adjustment_from_the_menu_reopens_the_last_settings` | +| Photo Filter remembers its last settings: Image › Adjustments › Photo Filter… opens at the filter, colour, density and Preserve Luminosity of its last OK | done (#9879): the other adjustments still open at their neutral settings and take the last ones with ⌥; a smart filter's dialog shows that filter's own settings | `adjust_dialog/tests.rs` `photo_filter_opens_at_its_last_settings` | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | | Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush, Pencil and Paint Bucket only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box is open they, and the number keys, still reach the tool (or the layers) without closing it; Puppet Warp and Perspective Warp keep them to themselves. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode, and Dodge, Burn and Sponge ignore them until their Range and mode keys (⇧⌥S/M/H, ⇧⌥D/S) exist | `blend_keys.rs` | @@ -252,6 +253,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Image › Adjustments › Photo Filter… opens at the filter and density of its last OK, without ⌥, as in Photoshop (#9879); no percentage changed | | 2026-10-27 | minor | Every preference takes effect: 12 of 181 do nothing, the scorecard's count with integration batch 9 (#9775) on main; new layers go above a group whose lock would refuse them (#4329) | | 2026-10-25 | minor | Every preference takes effect: 12 of 178 do nothing, the scorecard's count (it still said 46 of 149) | | 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) | From 744651bb4dd5bd4dce1a8662a2a1ca2cf7f06d21 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:08:52 +0100 Subject: [PATCH 041/384] Photo Filter: reopen with the choice its colour implies; complete recorded settings Review follow-ups for #9879: - Photo Filter's Filter / Color choice lives in the editor memory, so an opening that switched it and was cancelled left the next opening on Filter with a custom colour (the colour button disabled). Each opening now forgets it once, and the editor derives it from the colour again. - A smart filter's recorded adjustment settings are completed through the engine's parser before they fill the dialog, so a recorded Photo Filter that names only its `filter` shows and saves that preset's colour instead of the dialog's neutral one. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 17 +++++++++ .../src/dialogs/adjust_dialog/tests.rs | 35 +++++++++++++++++++ crates/ui-egui/src/dialogs/adjust_editors.rs | 7 ++++ .../src/dialogs/adjust_editors/basic.rs | 6 +++- crates/ui-egui/src/panels/smart_ui.rs | 5 ++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index 1ac70db208c..a1199ccea2e 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -17,6 +17,8 @@ use crate::tone::{self, HistSource}; const PREFIX: &str = "image.adjustments."; const CURVE_PICKER: &str = "__curvePicker"; const CURVE_PICKER_BASE: &str = "__curvePickerBase"; +/// Set once a Photo Filter dialog has drawn: its editor memory was refreshed for this opening. +const PHOTO_FILTER_FRESH: &str = "__photoFilterFresh"; fn picker(value: Option<&Value>) -> Option { use photocraft_engine::adjust_params::CurvesEyedropper; @@ -306,6 +308,15 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } +/// `params` of the adjustment `command` with every key filled in as the engine reads them: a +/// recorded Photo Filter that names only its `filter` gets that preset's `color`, not the dialog's +/// neutral one. `None` for other commands and for params the engine rejects. +pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { + let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; + let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; + Some(photocraft_engine::adjust_params::to_params(&adjustment)) +} + /// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a /// smart filter's settings is not a use of the dialog's own (as `filter_dialog::remember`). pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map) { @@ -340,6 +351,12 @@ fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); let mode = curve_mode(app, &kind); + // Each opening starts from the settings it opened at: Photo Filter's Filter / Color choice + // follows its colour, whatever a cancelled opening left in the editor memory. + if kind == "photoFilter" && !fields.contains_key(PHOTO_FILTER_FRESH) { + adjust_editors::forget_photo_filter_mode(ui.ctx(), dialog_mem(&kind)); + fields.insert(PHOTO_FILTER_FRESH.into(), json!(false)); + } if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index e2ba88ca61d..d94f783a851 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -885,3 +885,38 @@ fn photo_filter_opens_at_its_last_settings() { photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap()); assert_eq!(app.ui.dialog_mut(again).unwrap().fields["inBlack"], defaults["inBlack"], "Levels opens neutral without ⌥"); } + +/// #9879 review: a custom colour confirmed in Photo Filter reopens with Color chosen, even after an +/// opening that switched to Filter and was cancelled (the choice lives in the editor memory). +#[test] +fn photo_filter_reopens_with_the_choice_its_colour_implies() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let cmd = "image.adjustments.photoFilter"; + let id = open(h.state_mut(), cmd).unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("color".into(), json!("#123456")); + h.run_steps(2); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + // The next opening chooses Filter (what clicking its radio stores), then is cancelled. + let mode = dialog_mem("photoFilter").with(crate::adjust_editors::PHOTO_FILTER_MODE); + let id = open(h.state_mut(), cmd).unwrap(); + h.run_steps(2); + h.ctx.data_mut(|d| d.insert_temp(mode, false)); + h.state_mut().ui.close_dialog(id); + h.run_steps(1); + let id = open(h.state_mut(), cmd).unwrap(); + h.run_steps(2); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields["color"], json!("#123456")); + assert_eq!(h.ctx.data(|d| d.get_temp::(mode)), Some(true), "Color is chosen for the custom colour"); +} + +/// #9879 review: a recorded Photo Filter that names only its preset completes to that preset's +/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. +#[test] +fn recorded_photo_filter_settings_complete_to_their_preset_colour() { + let app = app_with_image(); + let done = complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "cooling80", "density": 60})).unwrap(); + assert_eq!((done["color"].as_str(), done["density"].as_f64()), (Some("#006dff"), Some(60.0))); + assert!(complete_params(&app, "filter.blur.gaussian", &json!({"radius": 2})).is_none(), "not an adjustment"); + assert!(complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "nope"})).is_none(), "rejected params"); +} diff --git a/crates/ui-egui/src/dialogs/adjust_editors.rs b/crates/ui-egui/src/dialogs/adjust_editors.rs index cdee0d65022..522ed46705b 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors.rs @@ -30,6 +30,7 @@ mod hue_saturation; mod levels; mod session; +pub(crate) use basic::PHOTO_FILTER_MODE; #[cfg(test)] pub(crate) use basic::row_value; #[cfg(test)] @@ -71,6 +72,12 @@ pub fn has_editor(kind: &str) -> bool { KINDS.contains(&kind) } +/// Forget Photo Filter's Filter / Color choice in the editor memory `mem`, so the editor derives it +/// from the colour again: a choice changed and then cancelled must not outlive the dialog (#9879). +pub(crate) fn forget_photo_filter_mode(ctx: &egui::Context, mem: egui::Id) { + ctx.data_mut(|d| d.remove::(mem.with(PHOTO_FILTER_MODE))); +} + /// Kinds that draw a histogram behind their controls. pub fn needs_histogram(kind: &str) -> bool { matches!(kind, "levels" | "curves" | "threshold") diff --git a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs index 18c740ab805..8a7ea8ce8eb 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/basic.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/basic.rs @@ -194,12 +194,16 @@ pub(super) fn black_white(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Ed // --------------------------------------------------------------------------------------------- // Photo Filter +/// The editor memory key of Photo Filter's Filter / Color choice (unset: Color for a colour that +/// is no preset). +pub(crate) const PHOTO_FILTER_MODE: &str = "pf-mode"; + pub(super) fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let mut e = Edit::default(); let mut c = rgb_of(v, "color", [236.0 / 255.0, 138.0 / 255.0, 0.0]); let hex = adjust_params::hex(c); let preset = PHOTO_FILTERS.iter().find(|f| adjust_params::hex(f.2.map(|x| f32::from(x) / 255.0)) == hex).map(|f| f.0); - let mode_id = cx.mem.with("pf-mode"); + let mode_id = cx.mem.with(PHOTO_FILTER_MODE); let mut use_color: bool = ui.data(|d| d.get_temp(mode_id)).unwrap_or(preset.is_none()); ui.horizontal(|ui| { if ui.radio(!use_color, tl!("Filter:")).clicked() { diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index c1b592be490..ff32958bb07 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -495,9 +495,12 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; + // An adjustment's recorded settings are completed first, so a key the record lacks shows (and + // saves) what the engine reads for it rather than the dialog's neutral value. + let params = crate::adjust_dialog::complete_params(app, &f.command, &f.params).unwrap_or_else(|| f.params.clone()); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = ¶ms { for (k, v) in p { if !k.starts_with("__") { d.fields.insert(k.clone(), v.clone()); From e74c79d015321a89f849fd91f8e85035b2ce1c96 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:28:54 +0100 Subject: [PATCH 042/384] Smart filter: complete only a Photo Filter's missing colour, keep recorded values Review follow-up for #9879: round-tripping every recorded adjustment through the engine rewrote values it should keep (a float Photo Filter colour became hex, a pencil curve's map came back smoothed while its `smooth` stayed in the stored params, so each confirmation smoothed it again). `complete_params` now adds just the colour a Photo Filter that names only its `filter` lacks, and leaves every recorded value and every other adjustment untouched. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 16 +++++++++++----- .../ui-egui/src/dialogs/adjust_dialog/tests.rs | 16 ++++++++++++---- crates/ui-egui/src/panels/smart_ui.rs | 4 ++-- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index a1199ccea2e..22c47b2960c 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -308,13 +308,19 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } -/// `params` of the adjustment `command` with every key filled in as the engine reads them: a -/// recorded Photo Filter that names only its `filter` gets that preset's `color`, not the dialog's -/// neutral one. `None` for other commands and for params the engine rejects. +/// `params` of the adjustment `command` with the keys the dialog would otherwise fill in wrongly: +/// a recorded Photo Filter that names only its `filter` gets that preset's `color`, not the +/// dialog's neutral one. Every recorded value is kept as it was (a round trip through the engine +/// would rewrite them: float colours as hex, a pencil curve smoothed again). `None` when nothing +/// needs adding, or for params the engine rejects. pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { - let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; + let kind = command.strip_prefix(PREFIX).filter(|k| *k == "photoFilter")?; + let recorded = params.as_object().filter(|p| p.contains_key("filter") && !p.contains_key("color"))?; let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; - Some(photocraft_engine::adjust_params::to_params(&adjustment)) + let color = photocraft_engine::adjust_params::to_params(&adjustment).get("color").cloned()?; + let mut done = recorded.clone(); + done.insert("color".into(), color); + Some(Value::Object(done)) } /// Remember an adjustment dialog's settings after a successful OK, for [`open_last`]. Editing a diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index d94f783a851..999ccb57009 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -911,12 +911,20 @@ fn photo_filter_reopens_with_the_choice_its_colour_implies() { } /// #9879 review: a recorded Photo Filter that names only its preset completes to that preset's -/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. +/// colour, so editing it as a smart filter doesn't swap in the dialog's neutral colour. Recorded +/// values are kept as they were, and nothing else is rewritten. #[test] fn recorded_photo_filter_settings_complete_to_their_preset_colour() { let app = app_with_image(); - let done = complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "cooling80", "density": 60})).unwrap(); - assert_eq!((done["color"].as_str(), done["density"].as_f64()), (Some("#006dff"), Some(60.0))); + let pf = "image.adjustments.photoFilter"; + let done = complete_params(&app, pf, &json!({"filter": "cooling80", "density": 60, "extra": [1]})).unwrap(); + assert_eq!(done, json!({"filter": "cooling80", "density": 60, "extra": [1], "color": "#006dff"}), "only the colour is added"); + // A recorded colour (here a float triple the engine would rewrite as hex) is left alone. + assert!(complete_params(&app, pf, &json!({"filter": "cooling80", "color": [0.5, 0.0, 0.0]})).is_none()); + assert!(complete_params(&app, pf, &json!({"density": 60})).is_none(), "no preset to take a colour from"); + assert!(complete_params(&app, pf, &json!({"filter": "nope"})).is_none(), "rejected params"); + // Other adjustments keep their recorded values exactly (a pencil curve isn't smoothed again). + let curves = json!({"pencil": true, "smooth": 2, "rgb": [0, 255]}); + assert!(complete_params(&app, "image.adjustments.curves", &curves).is_none()); assert!(complete_params(&app, "filter.blur.gaussian", &json!({"radius": 2})).is_none(), "not an adjustment"); - assert!(complete_params(&app, "image.adjustments.photoFilter", &json!({"filter": "nope"})).is_none(), "rejected params"); } diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index ff32958bb07..9071bca7b13 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -495,8 +495,8 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: }) else { return; }; - // An adjustment's recorded settings are completed first, so a key the record lacks shows (and - // saves) what the engine reads for it rather than the dialog's neutral value. + // A recorded Photo Filter that names only its preset gets the preset's colour, which the + // dialog would otherwise fill in with its neutral one; recorded values stay as they were. let params = crate::adjust_dialog::complete_params(app, &f.command, &f.params).unwrap_or_else(|| f.params.clone()); if let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("__smartFilter".into(), target); From f2a34f0326d958983917d1848131ad4e1be2f9ab Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:14 +0100 Subject: [PATCH 043/384] Smart filter: a null Photo Filter colour completes from its preset Review follow-up for #9879: the engine reads `"color": null` as no colour and uses the `filter` preset, so `complete_params` now completes it the same way instead of leaving the dialog on its neutral colour. Regression case added to `recorded_photo_filter_settings_complete_to_their_preset_colour`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog.rs | 3 ++- crates/ui-egui/src/dialogs/adjust_dialog/tests.rs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog.rs b/crates/ui-egui/src/dialogs/adjust_dialog.rs index 22c47b2960c..48e8b262109 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog.rs @@ -315,7 +315,8 @@ pub fn open_last(app: &mut PhotocraftApp, command: &str) -> Option { /// needs adding, or for params the engine rejects. pub(crate) fn complete_params(app: &PhotocraftApp, command: &str, params: &Value) -> Option { let kind = command.strip_prefix(PREFIX).filter(|k| *k == "photoFilter")?; - let recorded = params.as_object().filter(|p| p.contains_key("filter") && !p.contains_key("color"))?; + // A `null` colour reads as none, as the engine reads it. + let recorded = params.as_object().filter(|p| p.contains_key("filter") && p.get("color").is_none_or(Value::is_null))?; let adjustment = photocraft_engine::adjust_params::from_params(kind, params, None, curve_mode(app, kind)).ok()?; let color = photocraft_engine::adjust_params::to_params(&adjustment).get("color").cloned()?; let mut done = recorded.clone(); diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index 999ccb57009..b675e3d7691 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -921,6 +921,8 @@ fn recorded_photo_filter_settings_complete_to_their_preset_colour() { assert_eq!(done, json!({"filter": "cooling80", "density": 60, "extra": [1], "color": "#006dff"}), "only the colour is added"); // A recorded colour (here a float triple the engine would rewrite as hex) is left alone. assert!(complete_params(&app, pf, &json!({"filter": "cooling80", "color": [0.5, 0.0, 0.0]})).is_none()); + let null = complete_params(&app, pf, &json!({"filter": "cooling80", "color": null})).unwrap(); + assert_eq!(null["color"], json!("#006dff"), "a null colour completes from the preset, as the engine reads it"); assert!(complete_params(&app, pf, &json!({"density": 60})).is_none(), "no preset to take a colour from"); assert!(complete_params(&app, pf, &json!({"filter": "nope"})).is_none(), "rejected params"); // Other adjustments keep their recorded values exactly (a pencil curve isn't smoothed again). From 2780df1f30dd0b687c5e9dfd702b2c7da24ca91e Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:47:26 +0100 Subject: [PATCH 044/384] Camera Raw sidecars: apply the crop, stop reporting RawFileName A raw opened with a Lightroom or Camera Raw sidecar reported "3 not supported: CropBottom, CropRight, RawFileName" (#9985): the crop keys fell through to the unsupported list (CropTop/CropLeft were only spared because they were 0), and the raw's file name was treated as a setting. The XMP reader now resolves the crop from the public crs: schema: crs:HasCrop="True" with CropTop/CropLeft/CropBottom/CropRight, fractions of the developed frame in the raw's stored orientation. A missing or False HasCrop means no crop (the keys are stale and are not reported). Fractions are clamped to 0..1; an inverted, empty or unreadable crop, and a straightened one (CropAngle not 0, whose rectangle is measured on the rotated frame), is not applied and its keys are named as not supported. RawFileName is bookkeeping. Camera Raw's settings have no crop stage, so the crop is applied the way the sidecar's other raw-stage settings are: Open re-develops the raw (RawTuning::crop) and crops the upright frame after mapping the fractions through the raw's Orientation, before the Camera Raw step, so the post-crop vignette centres on the crop. This works with the dialog and with Open in Camera Raw off; the dialog previews the whole frame. PhotoCraft's own sidecar keeps the crop when it is rewritten, and Load Settings in the Camera Raw Filter names a crop as not supported. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/camera_raw_xmp.rs | 246 +++++++++++++++++- crates/io/src/raw.rs | 33 ++- crates/io/tests/it/raw.rs | 30 +++ .../dialogs/camera_raw_ui/identity_tests.rs | 1 + .../ui-egui/src/dialogs/camera_raw_ui/open.rs | 26 +- .../src/dialogs/camera_raw_ui/sidecar.rs | 10 +- .../src/dialogs/camera_raw_ui/tests.rs | 44 +++- docs/control-protocol.md | 5 +- docs/file-format-parity.md | 5 +- 9 files changed, 376 insertions(+), 24 deletions(-) diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 65df97e898c..76fd8bb0ca0 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -12,6 +12,13 @@ //! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature //! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no //! counterpart and are ignored. +//! +//! Crop (#9985): `crs:HasCrop="True"` with `crs:CropTop` / `CropLeft` / `CropBottom` / +//! `CropRight`, fractions of the developed frame in the raw's stored orientation (before its +//! Orientation tag turns it upright), is read into [`LoadReport::crop`]; the raw open crops the +//! document with it ([`crate::raw::RawTuning::crop`]). The settings have no crop (Camera Raw +//! Filter has none). Without `HasCrop`, or with `HasCrop="False"`, the crop keys are stale and +//! mean nothing. A straightened crop (`CropAngle` other than 0) isn't applied. use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; @@ -126,7 +133,6 @@ const METADATA: &[&str] = &[ "Version", "ProcessVersion", "HasSettings", - "HasCrop", "WhiteBalance", "ToneCurveName2012", "CameraProfile", @@ -147,16 +153,114 @@ const METADATA: &[&str] = &[ "SortName", "Group", "Description", + // The raw file the sidecar belongs to (Lightroom and Camera Raw name it). + "RawFileName", ]; +/// The crop's keys besides `HasCrop`, and their values without a crop (the whole frame, level). +pub const CROP_KEYS: [&str; 5] = ["CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle"]; +const NO_CROP: [f32; 5] = [0.0, 0.0, 1.0, 1.0, 0.0]; + +/// A Camera Raw crop: fractions of the developed frame in the raw's stored orientation, before +/// its Orientation tag turns it upright, with `0 <= left < right <= 1` and +/// `0 <= top < bottom <= 1`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub top: f32, + pub left: f32, + pub bottom: f32, + pub right: f32, +} + +impl Crop { + /// The crop of the frame turned upright by TIFF orientation `o` (1–8; anything else is 1), + /// as fractions of the upright frame: the corners map as the pixels do. + pub fn oriented(self, o: u16) -> Crop { + let map = |u: f32, v: f32| match o { + 2 => (1.0 - u, v), + 3 => (1.0 - u, 1.0 - v), + 4 => (u, 1.0 - v), + 5 => (v, u), + 6 => (1.0 - v, u), + 7 => (1.0 - v, 1.0 - u), + 8 => (v, 1.0 - u), + _ => (u, v), + }; + let (ax, ay) = map(self.left, self.top); + let (bx, by) = map(self.right, self.bottom); + Crop { top: ay.min(by), left: ax.min(bx), bottom: ay.max(by), right: ax.max(bx) } + } + + /// The crop on a `w × h` frame in pixels, `(x0, y0, x1, y1)` with `x1` and `y1` exclusive: + /// at least one pixel, inside the frame. `None` for an empty frame. + pub fn pixels(self, w: usize, h: usize) -> Option<(usize, usize, usize, usize)> { + let edge = |f: f32, n: usize| ((f64::from(f).clamp(0.0, 1.0) * n as f64).round() as usize).min(n); + let span = |a: f32, b: f32, n: usize| { + let lo = edge(a, n).min(n.checked_sub(1)?); + Some((lo, edge(b, n).max(lo + 1))) + }; + let (x0, x1) = span(self.left, self.right, w)?; + let (y0, y1) = span(self.top, self.bottom, h)?; + Some((x0, y0, x1, y1)) + } +} + /// What [`load`] applied. #[derive(Clone, Debug, Default, PartialEq)] pub struct LoadReport { - /// How many settings were applied. + /// How many settings were applied (a crop counts as one). pub applied: usize, /// `crs:` settings without a PhotoCraft control (set to something other than their neutral - /// value), or with an unusable value, in file order. + /// value), or with an unusable value, in file order; then the keys of a crop that isn't + /// applied (straightened, unreadable or empty). pub ignored: Vec, + /// The file's crop, when it has one that isn't the whole frame. The settings hold no crop, + /// so the caller applies it (a raw open crops the document) or reports it. + pub crop: Option, +} + +/// The crop keys as read, resolved by [`crop`] once the whole file is read. +#[derive(Default)] +struct CropFields { + has: Option, + values: [Option; 5], +} + +/// The file's crop from its keys: applied when `HasCrop` is `True` and the values make a level, +/// non-empty rectangle (fractions are clamped to 0..1); otherwise its keys are reported. +fn crop(fields: &CropFields, report: &mut LoadReport) { + match fields.has.as_deref().map(str::trim) { + // No crop: the other keys are what Camera Raw left from an earlier or reset crop. + None => return, + Some(v) if v.eq_ignore_ascii_case("false") => return, + Some(v) if v.eq_ignore_ascii_case("true") => {} + Some(_) => { + report.ignored.push("HasCrop".into()); + return; + } + } + let mut v = NO_CROP; + let mut usable = true; + for (slot, text) in v.iter_mut().zip(&fields.values) { + if let Some(text) = text { + match number(text) { + Some(n) => *slot = n, + None => usable = false, + } + } + } + let [top, left, bottom, right] = [v[0], v[1], v[2], v[3]].map(|f| f.clamp(0.0, 1.0)); + // A straightened crop's rectangle is measured on the rotated frame: not applied. + usable &= v[4] == 0.0 && top < bottom && left < right; + if !usable { + let present = CROP_KEYS.iter().zip(&fields.values).filter(|(_, t)| t.is_some()); + report.ignored.extend(present.map(|(k, _)| k.to_string())); + return; + } + if [top, left, bottom, right] != [0.0, 0.0, 1.0, 1.0] { + report.crop = Some(Crop { top, left, bottom, right }); + report.applied += 1; + } } fn number(s: &str) -> Option { @@ -197,8 +301,14 @@ fn neutral(name: &str, value: &str) -> bool { } } -fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { - if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { +fn apply(params: &mut CameraRaw, report: &mut LoadReport, crop: &mut CropFields, name: &str, value: &str) { + if name == "HasCrop" { + crop.has = Some(value.to_string()); + } else if let Some(i) = CROP_KEYS.iter().position(|k| *k == name) { + if let Some(slot) = crop.values.get_mut(i) { + *slot = Some(value.to_string()); + } + } else if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { match number(value) { Some(v) => { *field(params) = v.clamp(*lo, *hi); @@ -232,10 +342,11 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result let mut next = params.clone(); let mut report = LoadReport::default(); let mut found = false; + let mut crop_fields = CropFields::default(); for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { found = true; - apply(&mut next, &mut report, a.name(), a.value()); + apply(&mut next, &mut report, &mut crop_fields, a.name(), a.value()); } for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { found = true; @@ -254,13 +365,14 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result report.ignored.push(name.to_string()); } } else { - apply(&mut next, &mut report, name, c.text().unwrap_or("")); + apply(&mut next, &mut report, &mut crop_fields, name, c.text().unwrap_or("")); } } } if !found { return Err("no Camera Raw settings in the file".into()); } + crop(&crop_fields, &mut report); *params = next; Ok(report) } @@ -295,6 +407,11 @@ pub fn is_photocraft_xmp(bytes: &[u8]) -> bool { /// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. pub fn save(params: &CameraRaw) -> String { + save_with_crop(params, None) +} + +/// [`save`] with a raw file's crop, so rewriting a sidecar keeps the crop it was opened with. +pub fn save_with_crop(params: &CameraRaw, crop: Option) -> String { let mut p = params.clone(); let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; let mut out = String::from( @@ -315,6 +432,12 @@ pub fn save(params: &CameraRaw) -> String { } let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + if let Some(c) = crop { + out.push_str(&format!( + " crs:HasCrop=\"True\"\n crs:CropTop=\"{:.6}\"\n crs:CropLeft=\"{:.6}\"\n crs:CropBottom=\"{:.6}\"\n crs:CropRight=\"{:.6}\"\n crs:CropAngle=\"0\"\n", + c.top, c.left, c.bottom, c.right + )); + } out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); for (name, field) in CURVES { let points = field(&mut p); @@ -433,7 +556,7 @@ mod tests { abc"#; let r = load(&mut p, xmp).unwrap(); assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); - assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()], crop: None }); } #[test] @@ -479,4 +602,111 @@ mod tests { assert!(!is_photocraft_xmp(other), "{}", String::from_utf8_lossy(other)); } } + + /// A settings file with the `crs:` attributes `attrs`. + fn with(attrs: &str) -> String { + format!( + "" + ) + } + + fn report(attrs: &str) -> LoadReport { + load(&mut CameraRaw::default(), with(attrs).as_bytes()).unwrap() + } + + /// #9985: Lightroom's sidecar named the crop's bottom and right edges and the raw's file name + /// as unsupported; the crop is read now, and the file name is bookkeeping. + #[test] + fn reads_the_crop_and_not_the_file_name() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="0""#); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(r.crop, Some(Crop { top: 0.1, left: 0.2, bottom: 0.9, right: 0.75 })); + assert_eq!(r.applied, 1); + // Element form, and the keys spread over two descriptions, read the same. + let xmp = format!( + "True0.5" + ); + let r = load(&mut CameraRaw::default(), xmp.as_bytes()).unwrap(); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.0, bottom: 0.5, right: 0.5 }), "missing edges are the frame's"); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + } + + #[test] + fn no_crop_without_has_crop_true() { + let keys = r#"crs:CropTop="0.1" crs:CropLeft="0.2" crs:CropBottom="0.9" crs:CropRight="0.75" crs:CropAngle="3""#; + for has in ["", r#"crs:HasCrop="False""#, r#"crs:HasCrop="false""#] { + let r = report(&format!("{has} {keys}")); + assert_eq!((r.crop, r.ignored.clone(), r.applied), (None, Vec::new(), 0), "{has:?}: {r:?}"); + } + // The whole frame is no crop, and nothing to report. + let r = report(r#"crs:HasCrop="True" crs:CropTop="0" crs:CropLeft="0" crs:CropBottom="1" crs:CropRight="1" crs:CropAngle="0""#); + assert_eq!((r.crop, r.ignored.len()), (None, 0)); + let r = report(r#"crs:HasCrop="yes" crs:CropBottom="0.5""#); + assert_eq!((r.crop, r.ignored), (None, vec!["HasCrop".to_string()])); + } + + /// Hostile and straightened crops never panic: out-of-range fractions are clamped to the + /// frame; an inverted, empty, unreadable or straightened crop isn't applied and its keys are + /// named. + #[test] + fn unusable_crops_are_reported_not_applied() { + let r = report(r#"crs:HasCrop="True" crs:CropTop="-0.5" crs:CropLeft="0.25" crs:CropBottom="7" crs:CropRight="1.0000001""#); + assert_eq!(r.crop, Some(Crop { top: 0.0, left: 0.25, bottom: 1.0, right: 1.0 })); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + let all = || CROP_KEYS.iter().map(|k| k.to_string()).collect::>(); + for bad in [ + r#"crs:CropTop="0.8" crs:CropLeft="0.1" crs:CropBottom="0.2" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.5" crs:CropLeft="0.1" crs:CropBottom="0.5" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="2" crs:CropLeft="0.1" crs:CropBottom="3" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="NaN" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="inf" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="" crs:CropAngle="0""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="1.38""#, + r#"crs:CropTop="0.1" crs:CropLeft="0.1" crs:CropBottom="0.9" crs:CropRight="0.9" crs:CropAngle="NaN""#, + ] { + let r = report(&format!(r#"crs:HasCrop="True" {bad}"#)); + assert_eq!((r.crop, r.ignored, r.applied), (None, all(), 0), "{bad}"); + } + let r = report(r#"crs:HasCrop="True" crs:CropAngle="-2.5""#); + assert_eq!(r.ignored, vec!["CropAngle".to_string()], "only the keys the file has"); + } + + /// Corners map as `photocraft-raw`'s orientation maps pixels (`develop::source_xy`). + #[test] + fn crops_follow_the_orientation() { + let c = Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }; + let want = [ + (1, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + (2, Crop { top: 0.1, left: 0.5, bottom: 0.6, right: 0.8 }), + (3, Crop { top: 0.4, left: 0.5, bottom: 0.9, right: 0.8 }), + (4, Crop { top: 0.4, left: 0.2, bottom: 0.9, right: 0.5 }), + (5, Crop { top: 0.2, left: 0.1, bottom: 0.5, right: 0.6 }), + (6, Crop { top: 0.2, left: 0.4, bottom: 0.5, right: 0.9 }), + (7, Crop { top: 0.5, left: 0.4, bottom: 0.8, right: 0.9 }), + (8, Crop { top: 0.5, left: 0.1, bottom: 0.8, right: 0.6 }), + (0, Crop { top: 0.1, left: 0.2, bottom: 0.6, right: 0.5 }), + ]; + for (o, w) in want { + let got = c.oriented(o); + let close = [got.top - w.top, got.left - w.left, got.bottom - w.bottom, got.right - w.right].iter().all(|d| d.abs() < 1e-6); + assert!(close, "orientation {o}: {got:?}"); + } + assert_eq!(c.pixels(10, 20), Some((2, 2, 5, 12))); + assert_eq!(Crop { top: 0.5, left: 0.5, bottom: 0.5001, right: 0.5001 }.pixels(10, 10), Some((5, 5, 6, 6)), "at least a pixel"); + assert_eq!(Crop { top: 0.999, left: 0.999, bottom: 1.0, right: 1.0 }.pixels(10, 10), Some((9, 9, 10, 10)), "inside the frame"); + assert_eq!(c.pixels(0, 10), None); + } + + #[test] + fn save_with_crop_round_trips() { + let p = CameraRaw { contrast: 20.0, ..CameraRaw::default() }; + let crop = Crop { top: 0.125, left: 0.25, bottom: 0.875, right: 0.5 }; + let text = save_with_crop(&p, Some(crop)); + assert!(is_photocraft_xmp(text.as_bytes())); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert_eq!((back, r.crop), (p.clone(), Some(crop))); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(load(&mut CameraRaw::default(), save(&p).as_bytes()).unwrap().crop, None); + } } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index a1ed4aaa014..a1d1c730671 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -50,7 +50,7 @@ fn upright_preview(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> /// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, /// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the -/// developed RGB pixels, and the demosaic method. +/// developed RGB pixels, the demosaic method, and the crop. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -71,6 +71,9 @@ pub struct RawTuning { pub texture: f32, /// −100…100, as `CameraRaw::dehaze`. pub dehaze: f32, + /// The crop of the raw's sidecar ([`crate::camera_raw_xmp::Crop`], in the stored + /// orientation): the developed frame is cropped to it once it is upright. + pub crop: Option, } /// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure, @@ -106,7 +109,15 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< } None => false, }; - let dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let mut dev = photocraft_raw::develop_sensor(&sensor, &opts).map_err(IoError::Raw)?; + let cropped = match tuning.crop { + Some(crop) => { + let o = if opts.orient { dev.info.orientation } else { 1 }; + crop_developed(&mut dev, crop.oriented(o))?; + format!(", cropped to {}×{}", dev.width, dev.height) + } + None => String::new(), + }; let wb = if wb_applied { format!("as-shot white balance, temperature {:+.0}, tint {:+.0}", tuning.temperature, tuning.tint) } else { @@ -117,10 +128,26 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< .filter(|(_, v)| *v != 0.0) .map(|(k, v)| format!(", {k} {v:+.0}")) .collect::(); - let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic)", tuning.exposure, tuning.demosaic.id()); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb}, {} demosaic{cropped})", tuning.exposure, tuning.demosaic.id()); Ok((developed_document(name, bytes, format, dev, &summary)?, wb_applied)) } +/// Crops the upright developed frame to `crop` (fractions of it), as Camera Raw crops a raw. +fn crop_developed(dev: &mut photocraft_raw::Developed, crop: crate::camera_raw_xmp::Crop) -> Result<(), IoError> { + let (w, h) = (dev.width as usize, dev.height as usize); + let bad = || IoError::Raw(RawError::Malformed(format!("the developed {w}x{h} frame holds fewer samples than its size"))); + let Some((x0, y0, x1, y1)) = crop.pixels(w, h) else { return Ok(()) }; + let mut rgb = Vec::with_capacity((x1 - x0) * (y1 - y0) * 3); + for y in y0..y1 { + let row = y.checked_mul(w).ok_or_else(bad)?; + rgb.extend_from_slice(dev.rgb.get((row + x0) * 3..(row + x1) * 3).ok_or_else(bad)?); + } + dev.rgb = rgb; + dev.width = u32::try_from(x1 - x0).map_err(|_| bad())?; + dev.height = u32::try_from(y1 - y0).map_err(|_| bad())?; + Ok(()) +} + /// The tone curve `bytes` develop with by default: the fitted look's for a file without colour /// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw /// this crate decodes). For previews of the open-time raw-stage sliders diff --git a/crates/io/tests/it/raw.rs b/crates/io/tests/it/raw.rs index 52250c24d7d..12a3d362e4a 100644 --- a/crates/io/tests/it/raw.rs +++ b/crates/io/tests/it/raw.rs @@ -420,6 +420,36 @@ fn uncalibrated_raw_takes_its_colour_from_the_camera_jpeg() { assert_eq!(r.document.depth, SampleType::U16); } +/// #9985: a sidecar's crop is measured on the raw as stored, before its Orientation turns it +/// upright: the developed frame is cropped to the same sensor pixels, whichever way it is turned. +#[test] +fn camera_raw_tuning_crops_in_the_stored_orientation() { + use photocraft_io::camera_raw_xmp::Crop; + use photocraft_io::raw::{RawTuning, import_raw_tuned}; + let (w, h) = (40, 24); + // Stored pixels x 4..24, y 6..18; then the upright frame's size and the same pixels in it. + let crop = Crop { top: 0.25, left: 0.1, bottom: 0.75, right: 0.6 }; + for (orientation, size, [x0, y0, x1, y1]) in + [(1, (40, 24), [4, 6, 24, 18]), (3, (40, 24), [16, 6, 36, 18]), (6, (24, 40), [6, 4, 18, 24]), (8, (24, 40), [6, 16, 18, 36])] + { + let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 65535)); + spec.as_shot_neutral = Some([0.6, 1.0, 0.8]); + spec.orientation = orientation; + let bytes = spec.build(); + let (full, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning::default()).unwrap(); + let (cut, _) = import_raw_tuned("shot.dng", &bytes, &RawTuning { crop: Some(crop), ..Default::default() }).unwrap(); + assert_eq!((full.document.size.width, full.document.size.height), size, "orientation {orientation}"); + assert_eq!((cut.document.size.width, cut.document.size.height), (x1 - x0, y1 - y0), "orientation {orientation}"); + assert!(cut.warnings.iter().any(|n| n.contains(&format!("cropped to {}×{}", x1 - x0, y1 - y0))), "{:?}", cut.warnings); + let full = photocraft_io::document_to_image(&full.document, &mut Vec::new()).unwrap(); + let cut = photocraft_io::document_to_image(&cut.document, &mut Vec::new()).unwrap(); + // 16-bit RGB: 6 bytes a pixel. + let row = |y: u32| &full.data()[((y * size.0 + x0) * 6) as usize..((y * size.0 + x1) * 6) as usize]; + let expected: Vec = (y0..y1).flat_map(|y| row(y).to_vec()).collect(); + assert_eq!(cut.data(), expected, "orientation {orientation}: the crop is the same pixels of the uncropped develop"); + } +} + #[test] fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting() { use photocraft_io::raw::{RawTuning, fit_preview_look, import_raw_tuned}; diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs index a66e1721585..9ac37ee8351 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/identity_tests.rs @@ -54,6 +54,7 @@ fn identity_app() -> (PhotocraftApp, RawOpen, Control) { bytes: Some(bytes), developed_with: Default::default(), demosaic: Default::default(), + crop: None, }; let mut app = PhotocraftApp::new(session, crate::Services::default()); app.sync_views(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs index 87e1035a85b..8748bfe7eff 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/open.rs @@ -16,9 +16,9 @@ pub(super) enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open /// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture, -/// Dehaze or the demosaic method changed (those act on the sensor data, see -/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw -/// step, then marks the document unmodified. Cancel closes it. +/// Dehaze or the demosaic method changed, or the sidecar has a crop (those act on the sensor +/// data, see [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one +/// Camera Raw step, then marks the document unmodified. Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -30,6 +30,9 @@ pub struct RawOpen { pub developed_with: photocraft_io::raw::Demosaic, /// The method chosen in the dialog: Open re-develops when it differs from `developed_with`. pub demosaic: photocraft_io::raw::Demosaic, + /// The crop of the file's sidecar (in the raw's stored orientation): Open re-develops and + /// crops to it. The settings have no crop, so the dialog previews the whole frame. + pub crop: Option, } /// Should an interactive open of `name` show the open-time Camera Raw dialog? `warnings` are @@ -68,12 +71,13 @@ pub fn queue_open_dialog( bytes: bytes.map(|b| std::sync::Arc::new(b.to_vec())), developed_with: method, demosaic: method, + crop: None, }); Ok(()) } /// Opens the queued open-time dialog on its document's (only) layer. -pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Result<(), String> { +pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, mut raw: RawOpen) -> Result<(), String> { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; @@ -83,6 +87,7 @@ pub(super) fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpe let mut from_sidecar = false; if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) { params = loaded; + raw.crop = report.crop; from_sidecar = true; app.ui.status = if report.ignored.is_empty() { crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)]) @@ -179,6 +184,9 @@ pub(super) fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: clarity: params.clarity, texture: params.texture, dehaze: params.dehaze, + // The sidecar's crop: re-developing crops the document, before the Camera Raw step, so + // the post-crop vignette centres on the crop as in Camera Raw. + crop: raw.crop, }; // The open itself: the default settings, developed with the method it was opened with. let as_opened = photocraft_io::raw::RawTuning { demosaic: raw.developed_with, ..Default::default() }; @@ -349,9 +357,13 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, } let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return }; let method = photocraft_io::raw::developed_demosaic(warnings).unwrap_or_else(|| app.session.prefs().raw_defaults.demosaic.method()); - let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method }; + let mut raw = + RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None, developed_with: method, demosaic: method, crop: None }; let mut params = CameraRaw::default(); - let sidecar = path.and_then(read_sidecar).map(|(loaded, _, _)| loaded); + let sidecar = path.and_then(read_sidecar).map(|(loaded, report, _)| { + raw.crop = report.crop; + loaded + }); if let Some(loaded) = &sidecar { params = loaded.clone(); } else if app.session.prefs().raw_defaults.apply_auto_tone @@ -359,7 +371,7 @@ pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, { photocraft_algo::camera_raw::auto_tone(&px, &mut params); } - let r = if params.is_identity() { + let r = if params.is_identity() && raw.crop.is_none() { let defaults = app.session.prefs().raw_defaults.clone(); finish_open_raw(app, index, params, None, &raw, &defaults) } else { diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs index dfb03a13b9a..f370a6b68b2 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/sidecar.rs @@ -68,7 +68,8 @@ pub(super) fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &Cam None if params.is_identity() => return, _ => {} } - let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); + // The crop it opened with stays in the file, though the dialog has no control for it. + let bytes = photocraft_io::camera_raw_xmp::save_with_crop(params, raw.crop).into_bytes(); let Some(write) = app.services.write.as_mut() else { return }; if let Err(e) = write(&sidecar, &bytes) { crate::notices::error(app, crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &name), ("error", &e)])); @@ -124,7 +125,12 @@ pub(super) fn load_settings(app: &mut PhotocraftApp) -> Result { let file = crate::file_open::display_name(&name); let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; let mut next = d.params.clone(); - let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + let mut report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + // A crop only applies as a raw file opens; the filter has none, so it is not supported. + if report.crop.take().is_some() { + report.applied = report.applied.saturating_sub(1); + report.ignored.extend(photocraft_io::camera_raw_xmp::CROP_KEYS.iter().take(4).map(|k| k.to_string())); + } next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; if d.params.point_curve != next.point_curve { d.curve_state = Default::default(); diff --git a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs index bcae932f5e3..5da2e61d710 100644 --- a/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/camera_raw_ui/tests.rs @@ -558,7 +558,8 @@ fn cancel_closes_the_raw_and_the_preference_skips_the_dialog() { path: None, bytes: None, developed_with: Default::default(), - demosaic: Default::default() + demosaic: Default::default(), + crop: None } ) .is_err() @@ -801,6 +802,47 @@ fn a_rating_only_sidecar_is_left_alone() { let _ = std::fs::remove_dir_all(&dir); } +/// #9985: the crop of a Lightroom sidecar crops the opened raw (with the dialog's Open and +/// without the dialog), and neither its edges nor the raw's file name are named as unsupported. +/// A straightened crop isn't applied and is named. PhotoCraft's own sidecar keeps the crop. +#[test] +fn a_sidecar_crop_crops_the_opened_raw() { + let lightroom = r#""#; + let (mut app, dng, dir, raw_path, written) = raw_with_sidecar("crop", Some(lightroom)); + let ctx = egui::Context::default(); + let size = |app: &PhotocraftApp| app.session.active().map(|st| (st.doc.size.width, st.doc.size.height)); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("IMG_0100.xmp") && !app.ui.status.contains("not supported"), "{}", app.ui.status); + assert_eq!(size(&app), Some((48, 32)), "the dialog previews the whole frame"); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16)), "Open crops the 48x32 frame to x 6..30, y 8..24"); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "the crop is part of opening: unmodified"); + assert!(written.lock().unwrap().is_empty(), "Lightroom's sidecar is kept"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + app.open_file(&raw_path, &dng).unwrap(); + assert_eq!(size(&app), Some((24, 16)), "without the dialog too"); + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": true})).unwrap(); + // Straightened: the rectangle is measured on the turned frame, so nothing is cropped. + std::fs::write(sidecar_path(&raw_path), lightroom.replace(r#"crs:CropAngle="0""#, r#"crs:CropAngle="1.38""#)).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + assert!(app.ui.status.contains("CropAngle") && !app.ui.status.contains("RawFileName"), "{}", app.ui.status); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((48, 32))); + // PhotoCraft's own sidecar: a changed setting rewrites it, with the crop it was opened with. + let crop = photocraft_io::camera_raw_xmp::Crop { top: 0.25, left: 0.125, bottom: 0.75, right: 0.625 }; + std::fs::write(sidecar_path(&raw_path), photocraft_io::camera_raw_xmp::save_with_crop(&CameraRaw::default(), Some(crop))).unwrap(); + open_dialog_at(&mut app, &ctx, &dng, &raw_path); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert_eq!(size(&app), Some((24, 16))); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1); + let mut back = CameraRaw::default(); + let r = photocraft_io::camera_raw_xmp::load(&mut back, &w[0].1).unwrap(); + assert_eq!((back.contrast, r.crop), (15.0, Some(crop))); + let _ = std::fs::remove_dir_all(&dir); +} + /// With Open in Camera Raw off, opening a raw develops it with its sidecar and writes nothing /// back: nothing changed. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c286693c21..8f648c8c34c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -529,7 +529,10 @@ Open also applies the other Camera Raw Defaults (Sharpen For as part of the Came colour space, bit depth and Open as Smart Object, so the step works on the pixels the dialog previewed; Apply auto tone fills the dialog), reported as `defaults` in the commit's reply; the resolution is set when the file opens. With "Open in Camera Raw" off the sidecar and the defaults -apply without the dialog. +apply without the dialog. A sidecar's crop (`crs:HasCrop="True"` with `CropTop` / `CropLeft` / +`CropBottom` / `CropRight`, fractions of the raw as stored, before its Orientation) crops the +document as Open re-develops it, before the Camera Raw step; the dialog previews the whole frame. +A straightened crop (`CropAngle` not 0) is named as not supported and not applied. **Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the command line) of a camera raw developed from its sensor data shows this dialog first, titled diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f49fa461de9..63746addaff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-24 · **Change:** minor (PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Camera Raw: an `.xmp` sidecar's crop crops the opened raw, and `crs:RawFileName` is no longer named as unsupported, #9985; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -74,7 +74,7 @@ supports many more, including CR3). | Olympus ORF | uncompressed only | | | Fujifilm RAF (Bayer and X-Trans) | uncompressed only; no Fuji colour calibration | | | Others (CRW, 3FR, IIQ, X3F, SRW, MRW, ERF…) | embedded preview only | | -| .xmp sidecars, highlight modes, raw preferences | missing | FILE-215-11 | +| .xmp sidecars, highlight modes, raw preferences | partial (sidecars and preferences; no highlight modes) | 2026-10-28: a sidecar's crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the developed frame as stored, mapped through its Orientation) crops the opened raw: Open re-develops and crops before the Camera Raw step, so the post-crop vignette centres on the crop; the dialog previews the whole frame. A straightened crop (`CropAngle` not 0) isn't applied and is named, as are unreadable or empty ones; `HasCrop` missing or `False` means no crop; `RawFileName` is bookkeeping (#9985; `crates/io/src/camera_raw_xmp.rs` tests, `crates/io/tests/it/raw.rs` `camera_raw_tuning_crops_in_the_stored_orientation`, `camera_raw_ui/tests.rs` `a_sidecar_crop_crops_the_opened_raw`). 2026-10-13: sidecars fill the open-time dialog and Open writes them back; Camera Raw Defaults wired. FILE-215-11 | | Camera Raw as a PSD smart filter | partial | 2026-10-07: WB, Light/Presence, curves, HSL, grading, detail, grain, vignette mapped (FILE-215-12) | 2026-10-11: on #2135's embedded-JPEG rendering work, open-time Camera Raw tuning @@ -144,6 +144,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Camera Raw `.xmp` sidecars: the crop (`crs:HasCrop` with `CropTop` / `CropLeft` / `CropBottom` / `CropRight`, fractions of the raw as stored, mapped through its Orientation) crops the opened raw, with the dialog's Open and without the dialog; out-of-range fractions are clamped, and inverted, unreadable or straightened (`CropAngle` not 0) crops are named as not supported instead of applied; PhotoCraft's own sidecar keeps the crop when it is rewritten; `crs:RawFileName` is no longer named as unsupported (#9985) | | 2026-10-24 | minor | Layered TIFF: the `LMsk` block in tag 37724 carries Photoshop's default global layer mask (14 bytes) instead of none; with an empty block Photoshop read only the composite (#9095). PSD output unchanged | | 2026-10-24 | minor | Layered TIFF writes the TySh text version and descriptor in PSD big-endian order while retaining container order for the block header, transform matrix, warp section and bounds (#9098); the mixed-order read path remains covered by #4758/#4771. | | 2026-10-23 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels whose 1057 is exactly what PhotoCraft 0.1.0–0.16.0 wrote, and with no 1053 or one kept from an opened Photoshop file that doesn't list every channel, is read as those versions stored it (straight past the channels that 1053 lists), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | From ea4ec7b80e7a0627b66ba824d75d29e5d65b5beb Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 18:04:57 -0200 Subject: [PATCH 045/384] retouch: a wrapped stroke over its work budget names its own command periodic::Composite said paint.cloneStamp for every tool; Healing Brush, History Brush and Pattern Stamp now go through it too. The command is passed in, and the live stroke passes its own. --- crates/engine/src/cmd/retouch_cmds/clone.rs | 3 ++- crates/engine/src/cmd/retouch_cmds/heal.rs | 4 ++-- .../src/cmd/retouch_cmds/pattern_stamp.rs | 2 +- crates/engine/src/cmd/retouch_cmds/periodic.rs | 8 ++++++-- crates/engine/src/cmd/retouch_cmds/tests.rs | 18 ++++++++++++++++++ 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/clone.rs b/crates/engine/src/cmd/retouch_cmds/clone.rs index e0643d9fec2..41f168244fc 100644 --- a/crates/engine/src/cmd/retouch_cmds/clone.rs +++ b/crates/engine/src/cmd/retouch_cmds/clone.rs @@ -136,7 +136,7 @@ pub(super) fn clone_stamp(s: &mut Session, p: &Value) -> Result { return Ok(l.bounds); } if flag(p, "wrap", false) { - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |before, r| clone_sample(pre, id, before, which, r, &map, true, CMD)); } let (bounds, cov) = coverage(CMD, &stroke)?; // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. @@ -349,6 +349,7 @@ impl LiveRetouch { selection: self.sel.as_ref(), lock: self.lock, mode: self.mode, + cmd: live_cmd, } .draw(dirty, &self.brush, &self.points, paint) } diff --git a/crates/engine/src/cmd/retouch_cmds/heal.rs b/crates/engine/src/cmd/retouch_cmds/heal.rs index 791f65fc623..c68789e2c38 100644 --- a/crates/engine/src/cmd/retouch_cmds/heal.rs +++ b/crates/engine/src/cmd/retouch_cmds/heal.rs @@ -73,7 +73,7 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result { let mask: Vec = cov.iter().map(|c| *c > 0.0).collect(); let healed = heal_region(&fmt, &src, &dst, &mask); if wrap { - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| { + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| { let part = healed.crop(r); if part.rect == r { Ok(part) } else { Err(bad(CMD, "the healed patch does not cover the stroke")) } }); @@ -114,7 +114,7 @@ pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result { let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; if flag(p, "wrap", false) { // Pattern Preview: the history state repeats with the canvas, as the stroke does. - return periodic::stroke(pre, surf, &stroke, sel, lock, mode, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); + return periodic::stroke(pre, surf, &stroke, sel, lock, mode, CMD, |_, r| periodic::sample(pre, id, &src, SampleLayers::Current, r, CMD)); } let (bounds, cov) = coverage(CMD, &stroke)?; let paint = Region::read(&src, bounds); diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index 7ccb293d4f7..021e02a08a6 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -97,7 +97,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { Ok(apply_dab_stroke_wrapped(surf, &st.stroke, sel, lock, 0, wrap, effect)) } else if wrap.is_some() { let (place, fmt) = (st.placement(), surf.format()); - periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) + periodic::stroke(&pre, surf, &st.stroke, sel, lock, st.mode, CMD, |_, r| Ok(paint_region(&st.tile, &place, &fmt, r))) } else { paint_aligned(surf, &st.stroke, sel, lock, &st.tile, st.placement(), st.mode, CMD) } diff --git a/crates/engine/src/cmd/retouch_cmds/periodic.rs b/crates/engine/src/cmd/retouch_cmds/periodic.rs index 57527e16290..63c3b40f076 100644 --- a/crates/engine/src/cmd/retouch_cmds/periodic.rs +++ b/crates/engine/src/cmd/retouch_cmds/periodic.rs @@ -26,6 +26,7 @@ pub(super) fn project(rect: Rect, tile: Rect) -> Vec { /// A composite tool's stroke wrapped around the document: `paint` gives the pixels for a /// rectangle in the stroke's own coordinates, from the target as it was before the stroke, and /// they go on through the stroke's coverage wherever it repeats on the document. +#[allow(clippy::too_many_arguments)] pub(super) fn stroke( pre: &Document, target: &mut Surface, @@ -33,6 +34,7 @@ pub(super) fn stroke( selection: Option<&Surface>, lock: bool, mode: BlendMode, + cmd: &str, mut paint: impl FnMut(&Surface, Rect) -> Result, ) -> Result { let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); @@ -40,7 +42,7 @@ pub(super) fn stroke( renderer.finish(); let before = target.clone(); let bounds = renderer.bounds(); - Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode }.draw( + Composite { target, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection, lock, mode, cmd }.draw( bounds, &stroke.brush, &stroke.points, @@ -59,6 +61,8 @@ pub(super) struct Composite<'a> { pub selection: Option<&'a Surface>, pub lock: bool, pub mode: BlendMode, + /// The command an over-budget stroke's error names. + pub cmd: &'a str, } impl Composite<'_> { @@ -163,7 +167,7 @@ impl Composite<'_> { ); *work = work.saturating_add(r.width() as u64 * r.height() as u64); if *work > 64 * 1024 * 1024 { - return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget")); + return Err(bad(self.cmd, "wrapped stroke exceeds its coverage budget")); } ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths)); let mut sampled = None; diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index fe2ea3beabc..eb031a1a1f4 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2766,3 +2766,21 @@ fn wrapped_live_retouch_strokes_show_what_their_commits_paint() { } } } + +#[test] +fn a_wrapped_composite_stroke_over_its_work_budget_names_its_own_command() { + // Big, tightly spaced dabs pass the stroke footprint check but not the wrapped-work budget. + for (cmd, extra, setup) in [ + ("paint.historyBrush", json!({"state": 1}), textured_in_history as Setup), + ("paint.patternStamp", json!({"pattern": "Checkerboard"}), textured as Setup), + ] { + let mut s = session(WRAP.0, WRAP.1, 8, "rgb"); + setup(&mut s); + let mut p = json!({"points": [[0, 0], [700, 0]], "size": 1000, "hardness": 100, "spacing": 1, "wrap": true}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let err = s.execute(cmd, p).unwrap_err().to_string(); + assert!(err.contains(cmd) && err.contains("coverage budget"), "{cmd}: {err}"); + } +} From 77339962cc12ccda3ea6df858b8c9fb210b06868 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 16:49:12 +0100 Subject: [PATCH 046/384] xtask: panel-commands allowlist follows main's panels/ split; lexer helpers keep clippy's complexity limit Main moved crates/ui-egui/src/panels.rs into panels/*.rs, so the color_field entry now names panels/color.rs (scorecard note too), and main's cognitive_complexity deny split lex()'s block comment and string skipping into helpers. Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 2 +- scorecard/automation.toml | 2 +- xtask/src/panel_commands.rs | 94 +++++++++++++++++++++---------------- 3 files changed, 56 insertions(+), 42 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 3ef2d3940c9..2c9078f9ff4 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -262,7 +262,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs and file_cmds/batch_naming.rs: folder/array input, per-file errors, {name}/{serial}/{date} output templates and starting serial, Include All Subfolders (includeSubfolders: depth/loop/file-count bounded, results keep their subfolder; Batch and Image Processor dialogs, #3753); no open-documents source, cancel or error-log file | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | -| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors) | +| AUTO-219-8 | xtask check that every panel button routes through a command id | partial | [#219](https://github.com/storytold/photocraft/issues/219) | cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors) | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 573d1e7ec2f..137864c30e8 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -65,7 +65,7 @@ id = "AUTO-219-8" target = "xtask check that every panel button routes through a command id" status = "partial" issue = 219 -note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels.rs color_field assigns the foreground/background colour instead of tools.setColors)" +note = "cargo xtask panel-commands (run by xtask ci and CI) fails on a shell click handler that changes the document or session directly; 1 allowlisted function left (panels/color.rs color_field assigns the foreground/background colour instead of tools.setColors)" [[item]] id = "AUTO-219-9" diff --git a/xtask/src/panel_commands.rs b/xtask/src/panel_commands.rs index 5fd82abeb5e..eb929acfc73 100644 --- a/xtask/src/panel_commands.rs +++ b/xtask/src/panel_commands.rs @@ -50,7 +50,7 @@ pub const ALLOWLIST: &[Allowed] = &[ // background colour on every drag frame, so the change never reaches the journal, action // recording or the control channel's event log. Fix: dispatch `tools.setColors` (it already exists). Allowed { - file: "crates/ui-egui/src/panels.rs", + file: "crates/ui-egui/src/panels/color.rs", func: "color_field", reason: "Color panel field, hue strip and readout assign session.tools.foreground/background; dispatch tools.setColors", }, @@ -118,24 +118,7 @@ fn lex(src: &str) -> Vec { i += 1; } } else if ch == '/' && at(i + 1) == '*' { - let mut depth = 0usize; - while i < c.len() { - if at(i) == '/' && at(i + 1) == '*' { - depth += 1; - i += 2; - } else if at(i) == '*' && at(i + 1) == '/' { - depth = depth.saturating_sub(1); - i += 2; - if depth == 0 { - break; - } - } else { - if at(i) == '\n' { - line += 1; - } - i += 1; - } - } + (i, line) = skip_block_comment(&c, i, line); } else if let Some(hashes) = raw_string_start(&c, i) { // r"…", r#"…"#, br#"…"#: skip to the quote followed by the same number of hashes. let start_line = line; @@ -153,27 +136,7 @@ fn lex(src: &str) -> Vec { out.push(Token { kind: Kind::Other, line: start_line }); } else if ch == '"' || (ch == 'b' && at(i + 1) == '"') || (ch == 'c' && at(i + 1) == '"') { let start_line = line; - i += if ch == '"' { 1 } else { 2 }; - while i < c.len() { - match at(i) { - '\\' => { - // A line continuation (`\` before a newline) still ends a source line. - if at(i + 1) == '\n' { - line += 1; - } - i += 2; - } - '"' => { - i += 1; - break; - } - '\n' => { - line += 1; - i += 1; - } - _ => i += 1, - } - } + (i, line) = skip_string(&c, i + if ch == '"' { 1 } else { 2 }, line); out.push(Token { kind: Kind::Other, line: start_line }); } else if ch == '\'' || (ch == 'b' && at(i + 1) == '\'') { // Char literal ('a', '\n', b'x') or lifetime/label ('a, 'static). @@ -212,6 +175,57 @@ fn lex(src: &str) -> Vec { out } +/// Skip a (nested) block comment starting at `i`; returns the index after it and the new line. +fn skip_block_comment(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + let mut depth = 0usize; + while i < c.len() { + if at(i) == '/' && at(i + 1) == '*' { + depth += 1; + i += 2; + } else if at(i) == '*' && at(i + 1) == '/' { + depth = depth.saturating_sub(1); + i += 2; + if depth == 0 { + break; + } + } else { + if at(i) == '\n' { + line += 1; + } + i += 1; + } + } + (i, line) +} + +/// Skip a quoted string body starting just after its opening quote; returns the index after the +/// closing quote and the new line. +fn skip_string(c: &[char], mut i: usize, mut line: usize) -> (usize, usize) { + let at = |k: usize| c.get(k).copied().unwrap_or('\0'); + while i < c.len() { + match at(i) { + '\\' => { + // A line continuation (`\` before a newline) still ends a source line. + if at(i + 1) == '\n' { + line += 1; + } + i += 2; + } + '"' => { + i += 1; + break; + } + '\n' => { + line += 1; + i += 1; + } + _ => i += 1, + } + } + (i, line) +} + struct RawStart { /// Characters before the body (`r#"` is 3). prefix: usize, From 7a1d2dfd4c64bc19fca46cf8d5cefe1c88b9684a Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:27:14 +0100 Subject: [PATCH 047/384] Tests: image.crop rounds fractional rectangle sizes like its other pixel params Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds/tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/image_cmds/tests.rs b/crates/engine/src/cmd/image_cmds/tests.rs index fb6f1ec883d..d877e0270ef 100644 --- a/crates/engine/src/cmd/image_cmds/tests.rs +++ b/crates/engine/src/cmd/image_cmds/tests.rs @@ -427,6 +427,14 @@ fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() { s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap(); s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap(); assert_eq!(doc(&s).size, Size::new(30, 15)); + // Fractional pixels round, like the rectangle's `x`/`y` and every other pixel param + // (`commands::int`); a side that rounds to 0 is refused. + let mut s = session(); + s.execute("image.crop", json!({"x": 0.4, "y": 0.6, "width": 20.6, "height": 9.5})).unwrap(); + assert_eq!(doc(&s).size, Size::new(21, 10)); + let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": 0.4, "height": 5})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); + assert_eq!(doc(&s).size, Size::new(21, 10)); } #[test] From b309b2784471d94231745f07d0d76d7113734966 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 17:27:22 +0100 Subject: [PATCH 048/384] Address CodeRabbit review: the crop box's H, P and I refuse Alt too egui's `matches_logically(Modifiers::NONE)` ignores Alt as well as Shift, so Alt+H, Alt+P and Alt+I toggled Show Cropped Area, Classic Mode and Front Image; they are now left for later handlers. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/crop_shield.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tools/crop_shield.rs b/crates/ui-egui/src/tools/crop_shield.rs index eb8aeefacb8..e7d91c4e82e 100644 --- a/crates/ui-egui/src/tools/crop_shield.rs +++ b/crates/ui-egui/src/tools/crop_shield.rs @@ -146,12 +146,13 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } -/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧: egui ignores an extra ⇧ -/// there, but ⇧+letter is the tool key. Reads each event's own modifiers, which synthetic +/// `consume_key(Modifiers::NONE, key)` without the presses that hold ⇧ or ⌥: egui ignores an +/// extra ⇧ and ⌥ there, but ⇧+letter is the tool key and Alt+letter opens the menu with that +/// mnemonic (Alt+H Help, Alt+I Image). Reads each event's own modifiers, which synthetic /// (control channel) keys carry without changing the frame's. pub(crate) fn consume_unshifted(i: &mut egui::InputState, key: Key) -> bool { let before = i.events.len(); - i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && modifiers.matches_logically(Modifiers::NONE))); + i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && !modifiers.shift && !modifiers.alt && modifiers.matches_logically(Modifiers::NONE))); i.events.len() != before } @@ -398,6 +399,24 @@ mod tests { } } + /// Alt+H, Alt+P and Alt+I are not the crop box's H, P and I (Show Cropped Area, Classic Mode, + /// Front Image): egui's `consume_key` ignores an extra ⌥, so the plain-key checks must refuse + /// it and leave the press for later handlers. + #[test] + fn alt_letters_are_not_the_crop_box_keys() { + for key in [Key::H, Key::P, Key::I] { + let mut app = crop_app(); + let (shield, ratio) = (app.ui.tool_options.crop_shield.clone(), app.ui.tool_options.crop_ratio.clone()); + let ctx = egui::Context::default(); + let alt = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT }; + ctx.input_mut(|i| i.events.push(alt.clone())); + assert!(!keys(&mut app, &ctx) && !crate::crop_size::keys(&mut app, &ctx), "{key:?}"); + assert_eq!(app.ui.tool_options.crop_shield, shield, "{key:?}"); + assert_eq!(app.ui.tool_options.crop_ratio, ratio, "{key:?}"); + assert!(ctx.input(|i| i.events.contains(&alt)), "{key:?} is left for later handlers"); + } + } + #[test] fn the_color_picker_sets_the_custom_colour() { let mut app = crop_app(); From 763d193ecacba97788c3e4201584cf99c73db6c5 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 30 Oct 2026 22:41:02 +0700 Subject: [PATCH 049/384] fix(psd): a stored Puppet Warp mesh is drawn only while its triangles cover their box a bounded number of times The engine's stored-mesh limit matches the importer's (65 536 vertices). --- crates/algo/src/puppet.rs | 26 ++++++++++++++++++++++++-- crates/engine/src/cmd/distort_cmds.rs | 5 +++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/algo/src/puppet.rs b/crates/algo/src/puppet.rs index fe69f1ab334..ba5e2631f8d 100644 --- a/crates/algo/src/puppet.rs +++ b/crates/algo/src/puppet.rs @@ -1117,10 +1117,16 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out) } +/// How many times over a stored mesh's triangles may cover its deformed box, counting each +/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a +/// crafted one stacks large triangles to multiply the drawing work. +const MAX_MESH_COVERAGE: f64 = 16.0; + /// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file /// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored /// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex -/// that isn't finite) or one whose deformed box spans more than [`MAX_WARP_PIXELS`]. +/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose +/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times. pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option { let n = mesh.verts.len(); if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) { @@ -1135,7 +1141,20 @@ pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: for p in deformed { (x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1])); } - if (x1 - x0 + 1.0) * (y1 - y0 + 1.0) > MAX_WARP_PIXELS { + let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0); + if area > MAX_WARP_PIXELS { + return None; + } + let covered: f64 = mesh + .tris + .iter() + .map(|t| { + let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1])); + let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0; + span(xs) * span(ys) + }) + .sum(); + if covered > MAX_MESH_COVERAGE * area { return None; } let order: Vec = (0..mesh.tris.len()).collect(); @@ -1329,6 +1348,9 @@ mod tests { assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); far[2] = [1.0e6, 1.0e6]; assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none()); + // The same two triangles stacked 40 times: far more drawing than the box needs. + let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() }; + assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none()); let none = PuppetMesh { tris: Vec::new(), ..mesh }; assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none()); } diff --git a/crates/engine/src/cmd/distort_cmds.rs b/crates/engine/src/cmd/distort_cmds.rs index 2a2d965f08f..1be2b574273 100644 --- a/crates/engine/src/cmd/distort_cmds.rs +++ b/crates/engine/src/cmd/distort_cmds.rs @@ -86,8 +86,9 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result { Ok(PuppetWarp { pins, mode, density, expansion }) } -/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds). -const MAX_STORED_VERTS: usize = 1 << 20; +/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds); the +/// PSD importer reads no more. +const MAX_STORED_VERTS: usize = 1 << 16; /// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts", /// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of From 13f84218e3cdcc2ca39159d32bf51bd34becc8e4 Mon Sep 17 00:00:00 2001 From: qvest-ssels <214787280+qvest-ssels@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:04:59 +0100 Subject: [PATCH 050/384] feat(ui-egui): export dialogs start in the last exported folder (#10192) Export As, Quick Export and Save for Web now start in the folder of the last successful export (new preference export.lastFolder, kept across restarts), falling back to the document's folder when none is remembered or the folder is gone. Save As and non-export save dialogs are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/prefs.rs | 5 ++ crates/ui-egui/src/dialogs/export_dialog.rs | 8 +- crates/ui-egui/src/dialogs/file_ui/web.rs | 2 +- crates/ui-egui/src/file_dialog.rs | 70 ++++++++++++++-- crates/ui-egui/src/file_dialog/tests.rs | 92 +++++++++++++++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index f2445a2a547..6861adadad0 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -543,6 +543,10 @@ pub struct Export { pub webp_quality: u32, pub metadata: ExportMetadata, pub convert_to_srgb: bool, + /// The folder the last successful export through a save dialog went to (Export As, Quick + /// Export, Save for Web, ...). The next export dialog starts there; empty until the first + /// export, and ignored while the folder no longer exists (#10192). + pub last_folder: String, } impl Default for Export { @@ -555,6 +559,7 @@ impl Default for Export { webp_quality: 85, metadata: ExportMetadata::Copyright, convert_to_srgb: true, + last_folder: String::new(), } } } diff --git a/crates/ui-egui/src/dialogs/export_dialog.rs b/crates/ui-egui/src/dialogs/export_dialog.rs index 68f46af65f3..66efd1503ad 100644 --- a/crates/ui-egui/src/dialogs/export_dialog.rs +++ b/crates/ui-egui/src/dialogs/export_dialog.rs @@ -209,7 +209,7 @@ pub fn quick_export_selected_layers_png(app: &mut PhotocraftApp) -> Result = layers.into_iter().map(|id| photocraft_engine::layer_menu_cmds::layer_document(&st.doc, id).map_err(|e| e.to_string())).collect::>()?; let suggested = format!("{}.png", export_name(&st.doc.name)); - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { if !path.to_ascii_lowercase().ends_with(".png") { return Err("Quick Export as PNG writes .png files".into()); } @@ -1046,7 +1046,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, ids: &[photoc let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); let suggested = format!("{}.{}", export_name(stem), s_fmt(f)); let (f, settings) = (f.clone(), settings(f)); - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { let mut exported = Vec::new(); for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { let out = export_document(doc, &f, None, &source_profile)?; @@ -1145,7 +1145,7 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); let (suggested, doc) = (format!("{stem}.{fmt}"), st.doc.id); - return app.pick_save(&suggested, move |app, path| { + return app.pick_export(&suggested, move |app, path| { app.refocus(doc)?; quick_export(app, json!({"path": path})) }); diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index 62082b4f8d2..3cca5f84e34 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -535,7 +535,7 @@ pub(super) fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Re let st = WebSettings::from_params(&p, "file.export.saveForWebLegacy").map_err(|e| e.to_string())?; let base = photocraft_doc::slices::base_name(&doc); let suggested = if has_slices && html { format!("{base}.html") } else { format!("{base}.{}", st.format.ext()) }; - app.pick_save(&suggested, move |app, path| { + app.pick_export(&suggested, move |app, path| { app.refocus(doc.id)?; if has_slices { let dir = path.rfind(['/', '\\']).map(|i| path[..i].to_string()).unwrap_or_else(|| ".".into()); diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index bddc6796d3d..4a706970f23 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -148,17 +148,39 @@ impl PhotocraftApp { /// (#1826, #1866). A caller's explicit directory is kept. The chosen folder is remembered for /// the document the dialog was asked for. pub(crate) fn pick_save(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { + self.pick_save_in(suggested, false, then) + } + + /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): a + /// bare file name starts in the folder of the last successful export (a preference, kept + /// across restarts), as in Photoshop, and beside the document where that folder is unset or + /// gone (#10192, [`export_start_dir`]). Once `then` has exported, the chosen file's folder is + /// remembered; a failed or cancelled export leaves the remembered folder alone. + pub(crate) fn pick_export(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { + self.pick_save_in(suggested, true, then) + } + + fn pick_save_in( + &mut self, + suggested: &str, + export: bool, + then: impl FnOnce(&mut Self, String) -> Result + 'static, + ) -> Result { let mut suggested = std::path::PathBuf::from(suggested); let doc = self.session.active().map(|d| d.doc.id); // Closed documents' folders are no longer needed. let open: Vec<_> = self.session.documents().iter().map(|d| d.doc.id).collect(); self.save_dirs.retain(|id, _| open.contains(id)); - if suggested.parent().is_some_and(|p| p.as_os_str().is_empty()) - && let Some(dir) = doc + if suggested.parent().is_some_and(|p| p.as_os_str().is_empty()) { + let document_dir = doc .and_then(|d| self.save_dirs.get(&d).cloned()) - .or_else(|| self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(Path::to_path_buf)) - { - suggested = dir.join(suggested); + // An export doesn't start in a folder that is gone. + .filter(|dir| !export || folder_exists(dir)) + .or_else(|| self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(Path::to_path_buf)); + let remembered = export.then(|| self.session.prefs().export.last_folder.clone()); + if let Some(dir) = export_start_dir(remembered.as_deref(), document_dir, folder_exists) { + suggested = dir.join(suggested); + } } // The format comes from the extension, so a name typed without one (common where file // extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's @@ -174,7 +196,11 @@ impl PhotocraftApp { { app.save_dirs.insert(doc, dir.to_path_buf()); } - then(app, path) + let exported = then(app, path.clone())?; + if export { + app.remember_export_folder(&path); + } + Ok(exported) } _ => Err(UNEXPECTED.into()), })?; @@ -184,6 +210,16 @@ impl PhotocraftApp { Ok(asked) } + /// Remember the folder of an export that succeeded as where the next export dialog starts. + /// The web answers a download name, which has no folder, and leaves it alone. + fn remember_export_folder(&mut self, path: &str) { + let Some(dir) = Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()) else { return }; + let dir = dir.to_string_lossy().into_owned(); + if self.session.prefs().export.last_folder != dir { + self.session.prefs.edit(|p| p.export.last_folder = dir); + } + } + /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog /// answered `answered`, and [`saved_path`] changed it. A file at `path` that /// [`Services::same_file`](crate::Services::same_file) finds is the same entry as `answered` (the @@ -370,6 +406,28 @@ fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) } } +/// Whether `dir` is a folder on disk. The web has no file system, so nothing is remembered there. +#[cfg(not(target_arch = "wasm32"))] +fn folder_exists(dir: &Path) -> bool { + dir.is_dir() +} + +#[cfg(target_arch = "wasm32")] +fn folder_exists(_dir: &Path) -> bool { + false +} + +/// The folder an export dialog starts in (#10192): the `remembered` folder of the last export +/// while `exists` finds it, else the document's own folder (`document_dir`, whatever it is), +/// else none, which leaves the choice to the system dialog. +pub(crate) fn export_start_dir( + remembered: Option<&str>, + document_dir: Option, + exists: impl Fn(&Path) -> bool, +) -> Option { + remembered.filter(|d| !d.is_empty()).map(std::path::PathBuf::from).filter(|d| exists(d)).or(document_dir) +} + /// The folder an open dialog starts in: the directory of the most recent file (UI-217-3). pub(crate) fn last_used_dir(recent: &[String]) -> Option { let dir = std::path::Path::new(recent.first()?).parent()?; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 52149ab8e05..eed533891ec 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1484,3 +1484,95 @@ fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { app.poll_file_dialog(&ctx, None); assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); } + +/// #10192: the folder an export dialog starts in, as a pure decision. +#[test] +fn export_start_dir_prefers_an_existing_remembered_folder() { + use std::path::PathBuf; + let doc = Some(PathBuf::from("/proj")); + let all_exist = |_: &Path| true; + let none_exist = |_: &Path| false; + // Remembered and present: used. + assert_eq!(export_start_dir(Some("/renders"), doc.clone(), all_exist), Some(PathBuf::from("/renders"))); + // Remembered but gone: the document's folder. + assert_eq!(export_start_dir(Some("/renders"), doc.clone(), none_exist), doc); + // Nothing remembered (unset or empty): the document's folder, or nothing for an untitled one. + assert_eq!(export_start_dir(None, doc.clone(), all_exist), doc); + assert_eq!(export_start_dir(Some(""), doc.clone(), all_exist), doc); + assert_eq!(export_start_dir(None, None, all_exist), None); + assert_eq!(export_start_dir(Some("/renders"), None, none_exist), None); +} + +/// #10192: exports start in the last successfully exported folder, across documents; a failed or +/// cancelled export, a folder that is gone and other save dialogs change nothing. +#[test] +fn exports_start_in_the_last_exported_folder() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let root = tempfile::tempdir().unwrap(); + let renders = root.path().join("renders"); + std::fs::create_dir(&renders).unwrap(); + let renders_text = renders.to_string_lossy().into_owned(); + app.session.active_mut().unwrap().path = Some("/proj/original.psd".into()); + let suggested = |open: &Open| match &open.borrow()[0].0 { + FileDialogRequest::Save { suggested } => suggested.clone(), + other => panic!("save dialog expected, got {other:?}"), + }; + let remembered = |app: &PhotocraftApp| app.session.prefs().export.last_folder.clone(); + // No export yet: beside the document. Cancelled, nothing is remembered. + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "a.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), ""); + // The user picks another folder, but the export fails: nothing is remembered. + app.pick_export("a.png", |_, _| Err("disk full".into())).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), ""); + // A successful export is remembered... + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), renders_text); + // ...and starts the next export, of any document, even an untitled one. + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + app.pick_export("b.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined(&renders_text, "b.png")); + // A cancelled dialog changes nothing. + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(remembered(&app), renders_text); + // Other save dialogs keep their own start. + app.pick_save("c.psd", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), "c.psd"); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + // The folder disappears: back to the document's folder. + std::fs::remove_dir(&renders).unwrap(); + app.session.set_active(0); + app.pick_export("d.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "d.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); +} + +#[test] +fn the_last_export_folder_is_a_preference_that_old_files_lack() { + let prefs = photocraft_engine::prefs::Preferences::default(); + assert_eq!(prefs.export.last_folder, ""); + let mut json = serde_json::to_value(&prefs).unwrap(); + json["export"]["lastFolder"] = "/renders".into(); + let loaded: photocraft_engine::prefs::Preferences = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(loaded.export.last_folder, "/renders"); + // Settings saved before the field existed still load. + json["export"].as_object_mut().unwrap().remove("lastFolder"); + let old: photocraft_engine::prefs::Preferences = serde_json::from_value(json).unwrap(); + assert_eq!(old.export.last_folder, ""); +} From ebc0e164f41e06b1262bac7b1cee80cc9ae9f11c Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 31 Oct 2026 12:37:20 +0700 Subject: [PATCH 051/384] fix(io): restore the puppet module imports dropped when merging upstream main --- crates/io/src/smart_map/export.rs | 2 +- crates/io/src/smart_map/import.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/io/src/smart_map/export.rs b/crates/io/src/smart_map/export.rs index bf3b70d647d..bc96de4cd4e 100644 --- a/crates/io/src/smart_map/export.rs +++ b/crates/io/src/smart_map/export.rs @@ -7,7 +7,7 @@ use super::import::{ use super::placed::enumv; use super::{ Class, Descriptor, FilterStack, Id, J, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, camera_raw, displace, hue_sat, - perspective_warp, + perspective_warp, puppet, }; // ---------- writing ---------- diff --git a/crates/io/src/smart_map/import.rs b/crates/io/src/smart_map/import.rs index f945d5df9b1..e381efe0584 100644 --- a/crates/io/src/smart_map/import.rs +++ b/crates/io/src/smart_map/import.rs @@ -2,7 +2,7 @@ use super::export::{CELL_SIZE, blend_options, is_meta, pi, rgbc, whole}; use super::placed::enumv; use super::{ BlendMode, Descriptor, Id, J, Map, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, bool_or, camera_raw, displace, enum_of, get_desc, - hue_sat, json, num, perspective_warp, text_of, + hue_sat, json, num, perspective_warp, puppet, text_of, }; // ---------- the filters we model ---------- From 6f2fe52bb5387a680310a1bcb3b5cc00e081d916 Mon Sep 17 00:00:00 2001 From: qvest-ssels <214787280+qvest-ssels@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:38:51 +0100 Subject: [PATCH 052/384] fix(ui): a failed export doesn't change where the next export starts (#10192) The document's save folder is now stored for an export only once the export has succeeded; other saves keep storing it before writing, as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/file_dialog.rs | 12 ++++++++---- crates/ui-egui/src/file_dialog/tests.rs | 6 ++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 4a706970f23..e97318b2e28 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -191,13 +191,17 @@ impl PhotocraftApp { FileDialogAnswer::SaveTo(path) => { let path = saved_path(app, &path, suggested_ext.as_deref()); // The web answers a download name, which has no folder to remember. - if let Some(doc) = doc - && let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()) - { - app.save_dirs.insert(doc, dir.to_path_buf()); + let dir = doc.zip(Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf)); + // An export's folder only once it has exported: a failed one doesn't move where + // the next export starts. + if !export && let Some((doc, dir)) = dir.clone() { + app.save_dirs.insert(doc, dir); } let exported = then(app, path.clone())?; if export { + if let Some((doc, dir)) = dir { + app.save_dirs.insert(doc, dir); + } app.remember_export_folder(&path); } Ok(exported) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index eed533891ec..82ae0097aac 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1532,6 +1532,12 @@ fn exports_start_in_the_last_exported_folder() { answer(&open, Some(FileDialogAnswer::SaveTo(joined(&renders_text, "a.png")))); app.poll_file_dialog(&ctx, None); assert_eq!(remembered(&app), ""); + // ...and the next export still starts beside the document, not in the failed export's folder. + app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(suggested(&open), joined("/proj", "a.png")); + answer(&open, None); + app.poll_file_dialog(&ctx, None); // A successful export is remembered... app.pick_export("a.png", |_, _| Ok(Value::Null)).unwrap(); app.poll_file_dialog(&ctx, None); From 79ead9a4af28f76a93e20ac9951d2b7a8ac1f01f Mon Sep 17 00:00:00 2001 From: qvest-ssels <214787280+qvest-ssels@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:12:10 +0100 Subject: [PATCH 053/384] docs: tidy comments (review) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/file_dialog.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index e97318b2e28..cb30df9a442 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -151,11 +151,9 @@ impl PhotocraftApp { self.pick_save_in(suggested, false, then) } - /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): a - /// bare file name starts in the folder of the last successful export (a preference, kept - /// across restarts), as in Photoshop, and beside the document where that folder is unset or - /// gone (#10192, [`export_start_dir`]). Once `then` has exported, the chosen file's folder is - /// remembered; a failed or cancelled export leaves the remembered folder alone. + /// [`Self::pick_save`] for the export flows (Export As, Quick Export, Save for Web, ...): starts in + /// the folder [`export_start_dir`] picks (#10192), and once `then` has exported, remembers the + /// chosen file's folder in `export.lastFolder`; a failed or cancelled export leaves it alone. pub(crate) fn pick_export(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { self.pick_save_in(suggested, true, then) } From 42ab92c6229963b75f73b11b0c2ec8d11efd3c46 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 14:54:35 -0800 Subject: [PATCH 054/384] Preserve descriptor-free Image Size action steps --- crates/engine/src/cmd/atn_cmds.rs | 12 +++ crates/engine/src/cmd/atn_cmds/convert.rs | 13 ++- crates/engine/src/cmd/atn_cmds/menu_item.rs | 45 ++++++++++ crates/engine/tests/it/atn_menu_item.rs | 91 +++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/panels/actions.rs | 49 ++++++++++- crates/ui-egui/tests/it/atn_menu_item.rs | 89 ++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 9 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/cmd/atn_cmds/menu_item.rs create mode 100644 crates/engine/tests/it/atn_menu_item.rs create mode 100644 crates/ui-egui/tests/it/atn_menu_item.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479f..1b1af71e614 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,6 +32,7 @@ mod descriptors; mod events; mod fields; mod files; +mod menu_item; mod steps_doc; mod steps_image; mod steps_layer; @@ -54,6 +55,7 @@ use events::{EVENTS, event_and_descriptor, event_for}; #[cfg(test)] use fields::Mapped; use files::{load, save}; +pub use menu_item::{MENU_ITEM, menu_item_command}; #[cfg(test)] use steps_doc::crop_from; #[cfg(test)] @@ -127,6 +129,16 @@ pub fn specs() -> Vec { run: stop_message, journal: true, }, + CommandSpec { + id: MENU_ITEM, + label: "Menu Item", + menu: &[], + shortcut: None, + params: r##"{"id":"image.imageSize","eventKind":"name"|"code"="name"} (a descriptor-free Insert Menu Item step; opens Image Size during desktop playback, fails with interactive-required during headless playback)"##, + enabled: always, + run: menu_item::run, + journal: true, + }, CommandSpec { id: PHOTOSHOP_STEP, label: "Photoshop Step", diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 78d65979bf0..fe89ab2dfb9 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -66,10 +66,12 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); for (i, item) in a.items.iter().enumerate() { - let mapped = match (event_for(&item.event), &item.descriptor) { - (Some(e), Some(d)) => (e.from)(d), - (Some(e), None) => (e.from)(&Descriptor::default()), - (None, _) => Mapped::Unsupported { harmless: false }, + let menu = item.descriptor.is_none().then(|| super::menu_item::from_event(&item.event)).flatten(); + let mapped = match (menu, event_for(&item.event), &item.descriptor) { + (Some(p), _, _) => Mapped::Steps(vec![(super::MENU_ITEM.into(), p)]), + (_, Some(e), Some(d)) => (e.from)(d), + (_, Some(e), None) => (e.from)(&Descriptor::default()), + (_, None, _) => Mapped::Unsupported { harmless: false }, }; let (new_steps, enabled) = match mapped { Mapped::Steps(s) => (s, item.enabled), @@ -121,6 +123,9 @@ pub struct Skipped { } pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option { + if id == super::MENU_ITEM { + return super::menu_item::export(params); + } if id == PHOTOSHOP_STEP { let event = jstr(params, "event")?; let event = match params.get("eventKind") { diff --git a/crates/engine/src/cmd/atn_cmds/menu_item.rs b/crates/engine/src/cmd/atn_cmds/menu_item.rs new file mode 100644 index 00000000000..7eabb775df0 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/menu_item.rs @@ -0,0 +1,45 @@ +//! Descriptor-free menu intent is distinct from a command with recorded settings. +//! Only Image Size is supported here; other absent descriptors keep their existing mapping. + +use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json}; + +/// An Insert Menu Item step with no recorded command descriptor. +pub const MENU_ITEM: &str = "actions.menuItem"; + +/// Validate the supported menu intent before the shell gates and opens its actual command. +pub fn menu_item_command(p: &Value) -> Result<&str> { + let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?; + if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind")) { + return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings")); + } + if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) { + return Err(bad(MENU_ITEM, "`eventKind` must be name or code")); + } + match p.get("id").and_then(Value::as_str) { + Some("image.imageSize") => Ok("image.imageSize"), + _ => Err(bad(MENU_ITEM, "only the Image Size menu item is supported")), + } +} + +/// Preserve the exact code/name kind only for the supported descriptor-free event. +pub(super) fn from_event(event: &AtnEvent) -> Option { + let kind = match event { + AtnEvent::Code([b'I', b'm', b'g', b'S']) => "code", + AtnEvent::Name(name) if name == b"imageSize" => "name", + _ => return None, + }; + Some(json!({"id": "image.imageSize", "eventKind": kind})) +} + +/// Write absent settings as `None`, never as an empty or current-target descriptor. +pub(super) fn export(p: &Value) -> Option { + menu_item_command(p).ok()?; + let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name("imageSize") }; + Some(AtnItem { event, dictionary: b"Image Size".to_vec(), descriptor: None, ..Default::default() }) +} + +/// Headless execution cannot supply settings that were deliberately left for a dialog. +pub(super) fn run(_: &mut Session, p: &Value) -> Result { + let id = menu_item_command(p)?; + Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied"))) +} diff --git a/crates/engine/tests/it/atn_menu_item.rs b/crates/engine/tests/it/atn_menu_item.rs new file mode 100644 index 00000000000..d0f5c108e83 --- /dev/null +++ b/crates/engine/tests/it/atn_menu_item.rs @@ -0,0 +1,91 @@ +//! Descriptor-free Image Size means an interactive menu step, not empty settings. +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::json; + +fn file(item: AtnItem) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Menu"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Resize"), items: vec![item], ..Default::default() }], + expanded: true, + warnings: vec![], + }) + .unwrap() +} + +#[test] +fn absent_image_size_settings_round_trip_and_require_interaction_without_edits() { + for (event, kind) in [(AtnEvent::Code(*b"ImgS"), "code"), (AtnEvent::name("imageSize"), "name")] { + let bytes = file(AtnItem { event: event.clone(), descriptor: None, ..Default::default() }); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty(), "{:?}", imported.unsupported); + let action = &imported.actions[0]; + assert_eq!(action.steps, vec![("actions.menuItem".into(), json!({"id": "image.imageSize", "eventKind": kind}))]); + let (saved, skipped) = atn_cmds::export("Menu", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let reparsed = atn::parse(&saved).unwrap(); + let step = &reparsed.actions[0].items[0]; + assert_eq!(step.event, event); + assert!(step.descriptor.is_none()); + + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.actions.list = imported.actions; + let revision = session.active().unwrap().revision; + let journal = session.journal.len(); + let result = session.execute("actions.play", json!({"action": "Resize"})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result["failed"]["error"].as_str().is_some_and(|s| s.contains("interactive-required")), "{result}"); + assert_eq!(session.active().unwrap().revision, revision); + assert_eq!(session.journal.len(), journal); + } +} + +#[test] +fn empty_target_and_other_event_forms_keep_their_existing_meaning() { + let empty = Descriptor::new("null"); + let target = Descriptor { + items: vec![( + Id::Code(*b"null"), + D::Reference(vec![ReferenceItem::Enumerated { + class: Class { name: UnicodeString::default(), class_id: Id::Code(*b"Lyr ") }, + type_id: Id::Code(*b"Ordn"), + value: Id::Code(*b"Trgt"), + }]), + )], + ..empty.clone() + }; + for (event, descriptor) in [ + (AtnEvent::name("imageSize"), Some(empty)), + (AtnEvent::name("imageSize"), Some(target)), + (AtnEvent::name("ImgS"), None), + (AtnEvent::name("invert"), None), + ] { + let imported = atn_cmds::import(&file(AtnItem { event, descriptor: descriptor.clone(), ..Default::default() })).unwrap(); + assert_ne!(imported.actions[0].steps[0].0, "actions.menuItem"); + if imported.actions[0].steps[0].0 == atn_cmds::PHOTOSHOP_STEP { + let (saved, _) = atn_cmds::export("Menu", &[&imported.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor, descriptor); + } + } + // A wrapper cannot turn arbitrary commands or recorded settings into a menu dispatch. + for params in [json!({"id": "file.open"}), json!({"id": "image.imageSize", "width": 2}), json!({"id": "image.imageSize", "eventKind": 0})] { + let action = Action::new("Invalid", vec![("actions.menuItem".into(), params.clone())]); + assert!(atn_cmds::export("Menu", &[&action], &[], false).is_err()); + let mut session = Session::new(); + assert!(session.execute("actions.menuItem", params).is_err()); + } + // A normal recorded Image Size keeps its supplied settings and executes headlessly. + let action = Action::new("Recorded", vec![("image.imageSize".into(), json!({"width": 4, "height": 3, "resample": "nearest"}))]); + let (saved, _) = atn_cmds::export("Menu", &[&action], &[], false).unwrap(); + assert!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor.is_some()); + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + session.actions.list = atn_cmds::import(&saved).unwrap().actions; + let result = session.execute("actions.play", json!({"action": "Recorded"})).unwrap(); + assert_eq!(result["ran"], 1, "{result}"); + assert_eq!(session.active().unwrap().doc.size, photocraft_doc::Size::new(4, 3)); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f6a8360a4f6..382287a9c75 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -3,6 +3,7 @@ mod affinity; mod artboard_export_names; mod atn_crystallize_cell_size; +mod atn_menu_item; mod atn_ripple_amount; mod atn_selective_color; mod atn_stroke_width; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d38a75f1017..e1f57e10652 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -925,7 +925,7 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result) failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", actions_cmds::MAX_PLAYBACK_STEPS)})); break; } - let resolved = match actions_cmds::playback_params(&app.session, id, p) { + let menu = id == photocraft_engine::atn_cmds::MENU_ITEM; + let menu_command = if menu { + match photocraft_engine::atn_cmds::menu_item_command(p) { + Ok(id) => Some(id), + Err(e) => { + failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); + break; + } + } + } else { + None + }; + let id = menu_command.unwrap_or(id); + let resolved = match if menu { Ok(json!({})) } else { actions_cmds::playback_params(&app.session, id, p) } { Ok(p) => p, Err(e) => { failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); @@ -146,11 +159,29 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); break; } + if menu && app.automation_input { + failed = Some(json!({"step": i, "id": id, "error": format!("interactive-required: `{id}` needs its menu item dialog")})); + break; + } // The dialog toggle: show the step's dialog with its recorded values and continue // from the next step when it is confirmed (`resume`). Agents play headlessly. - if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) { + if menu || (action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id))) { let label = label_of(id); - let dialog = crate::dialogs::open_command_dialog(app, id, &label); + let dialog = if menu { + // Use the same populated Image Size dialog as the native menu, after the + // concrete command's gate and enablement check, rather than a bare confirm. + if let Err(e) = app.session.precheck(id, p) { + failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); + break; + } + let Some(dialog) = crate::sizing::open(app, id) else { + failed = Some(json!({"step": i, "id": id, "error": "no document"})); + break; + }; + dialog + } else { + crate::dialogs::open_command_dialog(app, id, &label) + }; let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); if let Some(d) = app.ui.dialog_mut(dialog) { if let Some(o) = p.as_object() { @@ -332,7 +363,9 @@ fn paint_check(ui: &egui::Ui, rect: Rect, on: bool, t: &Tokens) { } fn step_label(id: &str, params: &Value) -> String { - if id == "actions.play" { + if id == photocraft_engine::atn_cmds::MENU_ITEM { + photocraft_engine::atn_cmds::menu_item_command(params).map(label_of).unwrap_or_else(|_| label_of(id)) + } else if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) } else if id == "layer.select" { @@ -581,6 +614,14 @@ impl PausedPlaybacks { } } +/// Judge the dialog's final settings under the playback gate as well as the current gate. +pub(crate) fn authorize_dialog(app: &PhotocraftApp, fields: &serde_json::Map, id: &str, params: &Value) -> Result<(), String> { + if let Some(gate) = fields.get(RESUME).and_then(Value::as_u64).and_then(|token| app.paused_playbacks.paused.get(&token)).and_then(|paused| paused.gate) { + gate(id, params).map_err(|e| e.to_string())?; + } + Ok(()) +} + /// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this). /// /// The remaining steps run under the gate the playback paused with, however the dialog was diff --git a/crates/ui-egui/tests/it/atn_menu_item.rs b/crates/ui-egui/tests/it/atn_menu_item.rs new file mode 100644 index 00000000000..4f02ec26735 --- /dev/null +++ b/crates/ui-egui/tests/it/atn_menu_item.rs @@ -0,0 +1,89 @@ +//! Play a descriptor-free Image Size through its actual dialog and Actions continuation. +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; +use photocraft_ui_egui::{PhotocraftApp, dialogs}; +use serde_json::{Value, json}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + let action = Action::new("Menu", vec![(atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"}))]); + let (bytes, _) = atn_cmds::export("Menu", &[&action], &[], false).unwrap(); + app.session.actions.list = atn_cmds::import(&bytes).unwrap().actions; + app.session.actions.list[0].steps.push(("select.all".into(), json!({}))); + app +} + +#[test] +fn image_size_menu_item_opens_real_dialog_cancel_stops_and_confirm_resumes() { + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state(|ui, app: &mut PhotocraftApp| dialogs::show(app, ui.ctx()), app()); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + let revision = h.state().session.active().unwrap().revision; + let definition = h.state().session.actions.list[0].steps.clone(); + let result = h.state_mut().run("actions.play", json!({"action": "Menu"})).unwrap(); + let dialog = result["paused"]["dialog"].as_u64().unwrap(); + h.run_steps(3); + let fields = &h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap().fields; + assert_eq!(fields["__command"], "image.imageSize"); + assert_eq!(fields["__sizing"], true, "the populated sizing dialog, not a generic confirmation"); + assert_eq!(fields["width"], 8.0); + assert_eq!(fields["height"], 6.0); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + + let result = h.state_mut().run("actions.play", json!({"action": "Menu"})).unwrap(); + let dialog = result["paused"]["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("width".into(), json!(4)); + fields.insert("height".into(), json!(3)); + fields.insert("resample".into(), json!("nearest")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!(doc.size, photocraft_doc::Size::new(4, 3)); + assert!(doc.selection.is_some(), "the following step runs only after confirmation"); + assert_eq!(h.state().session.actions.list[0].steps, definition, "menu playback does not record the entered settings into the definition"); +} + +fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) } +} + +#[test] +fn menu_item_gates_its_actual_command_before_opening_a_dialog() { + let mut app = app(); + let revision = app.session.active().unwrap().revision; + app.session.authorize = Some(deny_resize); + let result = app.run("actions.play", json!({"action": "Menu"})).unwrap(); + assert_eq!(result["ran"], 0); + assert_eq!(result["failed"]["id"], "image.imageSize"); + assert!(result["failed"]["error"].as_str().is_some_and(|s| s.contains("resize denied")), "{result}"); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(app.session.active().unwrap().doc.selection.is_none()); +} + +fn deny_changed_width(id: &str, p: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" && p.get("width").is_some() { Err(EngineError::Other("entered resize denied".into())) } else { Ok(()) } +} + +#[test] +fn menu_item_confirmation_keeps_the_playback_gate_after_the_request_ends() { + let mut app = app(); + let revision = app.session.active().unwrap().revision; + app.session.authorize = Some(deny_changed_width); + let result = app.run("actions.play", json!({"action": "Menu"})).unwrap(); + app.session.authorize = None; + let dialog = result["paused"]["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("width".into(), json!(4)); + let error = dialogs::confirm(&mut app, dialog).unwrap_err(); + assert!(error.contains("entered resize denied"), "{error}"); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(app.session.active().unwrap().revision, revision); + assert!(app.session.active().unwrap().doc.selection.is_none()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index e8d8b334ba9..eb6c9f19330 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -2,6 +2,7 @@ mod adjust_preview_gpu; mod arbitrary_rotation_preview; +mod atn_menu_item; mod batch_export_options; mod camera_raw_histogram; mod camera_raw_localization; From 33f8f18183eb8b406381df4ce3551bd83ee44d48 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 15:08:52 -0800 Subject: [PATCH 055/384] Add action and step duplication --- crates/engine/src/cmd/actions_edit_cmds.rs | 85 ++++++++++++- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/convert.rs | 6 +- crates/engine/tests/it/actions_duplicate.rs | 122 +++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/panels/actions.rs | 32 ++++- crates/ui-egui/tests/it/actions_duplicate.rs | 53 ++++++++ crates/ui-egui/tests/it/main.rs | 1 + 8 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/it/actions_duplicate.rs create mode 100644 crates/ui-egui/tests/it/actions_duplicate.rs diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index 1f9cce3431b..5290299ae5d 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -1,14 +1,15 @@ //! Editing an action's steps and its set, as the Actions panel does beyond recording: //! `actions.insert` (Photoshop's Insert Stop… and Insert Menu Item…), `actions.dialog` (the //! panel's dialog toggle column) and `actions.assignSet` (which set folder an action sits in). +//! `actions.duplicate` copies an action or step below its source, retaining settings and flags. //! -//! All three refuse to run during playback, commit pending recorded steps first, and keep the +//! Definition edits refuse to run during playback, commit pending recorded steps first, and keep the //! per-step flag sets (`disabled`, `dialogs`) in step with the moved indices, like //! `actions.move` and `actions.delete`. use serde_json::{Value, json}; -use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve}; +use crate::actions_cmds::{Action, flush_recording, not_playing, pending_steps, resolve}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; @@ -65,11 +66,79 @@ fn insert(s: &mut Session, p: &Value) -> Result { if s.actions.recording.is_some_and(|(_, i)| i == idx) { flush_recording(s)?; } + Ok(insert_step(s, idx, at, (id, params))) +} + +/// Insert an already validated step without normalizing a copied step's payload. +fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value { let action = &mut s.actions.list[idx]; - action.steps.insert(at, (id.clone(), params)); + let id = step.0.clone(); + action.steps.insert(at, step); action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()}); s.actions.touch(); + result +} + +/// Keep session-list Play Action targets stable when a copied action inserts a row. +/// Other commands' nested JSON may own a separate action library and is not traversed. +fn shift_action_calls(actions: &mut [Action], at: usize) { + for (id, params) in actions.iter_mut().flat_map(|a| &mut a.steps) { + if id != "actions.play" { + continue; + } + let Some(Value::Number(n)) = params.get("action") else { continue }; + let shifted = if let Some(n) = n.as_u64() { + if n >= at as u64 { n.checked_add(1).map(Value::from) } else { None } + } else { + // `actions.play` also accepts floating indices, rounding them before lookup. + n.as_f64().filter(|n| n.round() >= at as f64).map(|n| Value::from(n + 1.0)) + }; + if let Some(value) = shifted { + params["action"] = value; + } + } +} + +/// Duplicate a step or action below its source, preserving settings and per-step flags. +fn duplicate(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + not_playing(s, cmd)?; + let idx = resolve(&s.actions.list, p, cmd)?; + if let Some(step) = index(p, "step", cmd)? { + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + if step >= len { + return Err(bad(cmd, "step is out of range")); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &s.actions.list[idx]; + let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?; + let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step)); + let at = step + 1; + let mut result = insert_step(s, idx, at, copied); + let action = &mut s.actions.list[idx]; + if disabled { + action.disabled.insert(at); + } + if dialog { + action.dialogs.insert(at); + } + result["index"] = json!(idx); + return Ok(result); + } + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating an action")); + } + let mut copied = s.actions.list[idx].clone(); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + copied.name = names.claim(&format!("{} copy", copied.name)); + let at = idx + 1; + let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); + s.actions.list.insert(at, copied); + shift_action_calls(&mut s.actions.list, at); + s.actions.touch(); Ok(result) } @@ -245,6 +314,16 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.duplicate", + label: "Duplicate", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index?} → {action,index,step?,steps}. Duplicate the action, or one step, directly below its source. Action names gain " copy" and a unique numeric suffix if needed; settings, flags, set and color are retained. Function-key bindings stay with their original action. Stop recording before duplicating a whole action. Set duplication is not supported."##, + enabled: always, + run: duplicate, + journal: false, + }, CommandSpec { id: "actions.insert", label: "Insert Step", diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 1b1af71e614..a7e38f93006 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -41,6 +41,7 @@ mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; +pub(crate) use convert::UniqueNames; pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index fe89ab2dfb9..d2fe8571de8 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -250,18 +250,18 @@ pub fn export( /// Assigns names not already used, as `name`, `name 2`, `name 3`… A plain linear scan of the /// taken list per candidate is cubic when an action file repeats one base name many times, so /// membership uses a set and the next suffix to try is cached per base name. -pub(super) struct UniqueNames { +pub(crate) struct UniqueNames { taken: std::collections::HashSet, next_suffix: std::collections::HashMap, } impl UniqueNames { - pub(super) fn new>(existing: I) -> Self { + pub(crate) fn new>(existing: I) -> Self { UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() } } /// Reserves and returns a unique name derived from `name`, recording it as taken. - pub(super) fn claim(&mut self, name: &str) -> String { + pub(crate) fn claim(&mut self, name: &str) -> String { if self.taken.insert(name.to_string()) { return name.to_string(); } diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs new file mode 100644 index 00000000000..e5c97d294d3 --- /dev/null +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -0,0 +1,122 @@ +//! Duplicate keeps action identities, complete settings and per-step flags stable. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::{Value, json}; + +/// A tiny document to distinguish definition edits from document mutations and playback. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s +} + +#[test] +fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { + let mut s = session(); + let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("layer.select".into(), json!({"layer": "Photo"}))]); + source.disabled.insert(1); + source.dialogs.insert(0); + source.set = Some("Retouch".into()); + source.color = "red".into(); + let foreign = json!({"actions": [{"name": "Foreign", "steps": [["actions.play", {"action": 1}]]}]}); + s.actions.list = vec![ + source.clone(), + Action::new("Target", vec![("select.all".into(), json!({}))]), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": 3})), // Unresolved before and after insertion. + ("actions.play".into(), json!({"action": 1.0})), + ("file.automate.batch".into(), foreign.clone()), + ("actions.conditional".into(), json!({"if": "always", "then": "Target", "else": "Source"})), + ], + ), + ]; + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:Source".into(), "F6".into()); + }); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": "Source"})).unwrap(); + assert_eq!(result["index"], 1); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Source", "Source copy", "Target", "Caller"]); + let mut expected = source; + expected.name = "Source copy".into(); + expected.steps[0].1["action"] = json!(2); + assert_eq!(s.actions.list[1], expected); + assert_eq!(s.actions.list[0].steps[0].1["action"], 2); + let caller = &s.actions.list[3]; + assert_eq!(caller.steps[0].1["action"], 2); + assert_eq!(caller.steps[1].1["action"], 4); + assert_eq!(caller.steps[2].1["action"], 2.0); + assert_eq!(caller.steps[3].1, foreign, "embedded libraries keep their own indices"); + assert_eq!(caller.steps[4].1, json!({"if": "always", "then": "Target", "else": "Source"})); + assert_eq!(s.prefs().shortcuts.get("actions.play:Source").map(String::as_str), Some("F6")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Source copy")); + assert_eq!(s.active().unwrap().revision, revision); + let played = s.execute("actions.play", json!({"action": "Source copy"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.is_some(), "the copied call still plays Target rather than itself"); + s.execute("actions.duplicate", json!({"action": 0})).unwrap(); + assert_eq!(s.actions.list[1].name, "Source copy 2"); + assert_eq!(s.actions.list[2].name, "Source copy"); + assert_eq!(s.actions.list[1].steps[0].1["action"], 3); +} + +#[test] +fn step_copies_preserve_payloads_and_shift_both_flag_columns() { + let mut s = session(); + let payload = json!({"layer": "Photo", "_actionLayer": {"name": "Photo", "ordinal": 2}}); + let mut a = Action::new("Steps", vec![("layer.select".into(), payload.clone()), ("select.all".into(), Value::Null), ("select.deselect".into(), json!({}))]); + a.disabled.extend([0, 2]); + a.dialogs.extend([0, 1]); + s.actions.list.push(a); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(result["step"], 1); + assert_eq!(result["index"], 0); + let a = &s.actions.list[0]; + assert_eq!(a.name, "Steps"); + assert_eq!(a.steps[0], a.steps[1]); + assert_eq!(a.disabled.iter().copied().collect::>(), [0, 1, 3]); + assert_eq!(a.dialogs.iter().copied().collect::>(), [0, 1, 2]); + s.execute("actions.duplicate", json!({"action": 0, "step": 2})).unwrap(); + assert!(s.actions.list[0].steps[2].1.is_null()); + assert!(s.actions.list[0].steps[3].1.is_null(), "copying does not normalize null settings to an empty object"); + s.actions.list[0].steps[1].1["layer"] = json!(99); + assert_eq!(s.actions.list[0].steps[0].1, payload, "the copy owns its settings"); + assert_eq!(s.active().unwrap().revision, revision); +} + +#[test] +fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() { + let mut s = session(); + s.execute("actions.record", json!({"name": "Recording"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let before = s.actions.list.clone(); + let recording = s.actions.recording; + let revision = s.active().unwrap().revision; + for params in [ + json!({}), + json!({"action": 9}), + json!({"action": 0}), + json!({"action": 0, "step": -1}), + json!({"action": 0, "step": 5}), + json!({"action": 0, "step": "x"}), + ] { + assert!(s.execute("actions.duplicate", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, before); + assert_eq!(s.actions.recording, recording); + assert_eq!(s.active().unwrap().revision, revision); + } + s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("select.all".into(), json!({})); 2]); + assert!(s.actions.recording.is_some()); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.len(), 3); + s.actions.list.push(Action::new("Edit during playback", vec![("actions.duplicate".into(), json!({"action": 0}))])); + let before = s.actions.list.clone(); + let played = s.execute("actions.play", json!({"action": "Edit during playback"})).unwrap(); + assert!(played["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{played}"); + assert_eq!(s.actions.list, before); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 382287a9c75..7528a8c580c 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; mod affinity; mod artboard_export_names; mod atn_crystallize_cell_size; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 5683afd88ae..f096ef49523 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -560,11 +560,11 @@ fn suspended(app: &PhotocraftApp) -> bool { } /// While a step dialog holds a suspended recording, refuse what would lose its call: a new -/// recording (the restore would find recording taken) or deleting/moving actions (the recorded +/// recording (the restore would find recording taken) or deleting/moving/duplicating actions (the recorded /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) { + if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate") && suspended(app) { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -681,6 +681,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled(!recording, egui::Button::new(tl!("New Action…"))).clicked() { act = Some("new"); } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Duplicate"))).clicked() { + act = Some("duplicate"); + } if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { act = Some("play"); } @@ -726,6 +729,7 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { delete_selection(app); Ok(Value::Null) } + "duplicate" => duplicate_selection(app), "load" => load_dialog(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), @@ -789,6 +793,30 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { } } +/// Keep the selection and disclosure rows on the new copy after the engine edits the list. +fn duplicate_selection(app: &mut PhotocraftApp) -> Result { + let action = app.ui.actions.selected.ok_or_else(|| tl!("Select an action first").to_string())?; + let step = app.ui.actions.selected_step; + let count = app.session.actions.list.len(); + let expanded = app.ui.actions.expanded.get(action).copied().unwrap_or(false); + let result = app.run("actions.duplicate", json!({"action": action, "step": step}))?; + let index = result["index"].as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("the copy has no action index")?; + app.ui.actions.selected = Some(index); + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + if step.is_none() { + app.ui.actions.expanded.resize(count, false); + if index <= app.ui.actions.expanded.len() { + app.ui.actions.expanded.insert(index, expanded); + } + } else { + app.ui.actions.expanded.resize(count, false); + if let Some(expanded) = app.ui.actions.expanded.get_mut(index) { + *expanded = true; + } + } + Ok(result) +} + fn delete_selection(app: &mut PhotocraftApp) { let Some(action) = app.ui.actions.selected else { return }; let step = app.ui.actions.selected_step; diff --git a/crates/ui-egui/tests/it/actions_duplicate.rs b/crates/ui-egui/tests/it/actions_duplicate.rs new file mode 100644 index 00000000000..a9f9aeb206b --- /dev/null +++ b/crates/ui-egui/tests/it/actions_duplicate.rs @@ -0,0 +1,53 @@ +//! The panel duplicates the selected action/step and keeps its new row selected. +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_ui_egui::{PhotocraftApp, actions, dialogs}; +use serde_json::json; + +#[test] +fn duplicate_menu_selects_the_new_action_and_then_the_new_step() { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + let mut a = Action::new("Source", vec![("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + app.session.actions.list.push(a); + app.ui.actions.selected = Some(0); + app.ui.actions.expanded = vec![true]; + let mut h = Harness::builder().with_size(egui::vec2(360.0, 460.0)).build_ui_state(|ui, app| actions::panel_menu(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list.len(), 2); + assert_eq!(h.state().session.actions.list[1].name, "Source copy"); + assert_eq!(h.state().ui.actions.selected, Some(1)); + assert_eq!(h.state().ui.actions.selected_step, None); + assert_eq!(h.state().ui.actions.expanded, [true, true]); + h.state_mut().ui.actions.selected_step = Some(0); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected, Some(1)); + assert_eq!(h.state().ui.actions.selected_step, Some(1)); + let copied = &h.state().session.actions.list[1]; + assert_eq!(copied.steps.len(), 2); + assert_eq!(copied.disabled.iter().copied().collect::>(), [0, 1]); + assert_eq!(copied.dialogs.iter().copied().collect::>(), [0, 1]); +} + +#[test] +fn duplicate_cannot_shift_the_owner_of_a_suspended_recording() { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.session.actions.list.push(Action::new("Menu", vec![(atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"}))])); + app.run("actions.record", json!({"name": "Owner"})).unwrap(); + let result = app.run("actions.play", json!({"action": "Menu"})).unwrap(); + let dialog = result["paused"]["dialog"].as_u64().unwrap(); + let list = app.session.actions.list.clone(); + assert!(app.session.actions.recording.is_none()); + let error = app.run("actions.duplicate", json!({"action": "Menu"})).unwrap_err(); + assert!(error.contains("Confirm or cancel"), "{error}"); + assert_eq!(app.session.actions.list, list); + dialogs::cancel(&mut app, dialog).unwrap(); + assert_eq!(app.session.actions.recording.map(|(_, i)| i), Some(1)); + app.run("actions.stop", json!({})).unwrap(); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index eb6c9f19330..61be475d521 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod actions_duplicate; mod adjust_preview_gpu; mod arbitrary_rotation_preview; mod atn_menu_item; From dcf68b5b86aa03b20a8b53cca94629160aecb86f Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 15:21:24 -0800 Subject: [PATCH 056/384] Confirm clearing the Actions library --- crates/engine/src/cmd/actions_edit_cmds.rs | 30 +++++ crates/engine/tests/it/actions_clear_all.rs | 55 +++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/control/dispatch.rs | 1 + crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/panels/actions.rs | 8 +- .../ui-egui/src/panels/actions/clear_all.rs | 53 +++++++++ crates/ui-egui/tests/it/actions_clear_all.rs | 110 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 9 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/it/actions_clear_all.rs create mode 100644 crates/ui-egui/src/panels/actions/clear_all.rs create mode 100644 crates/ui-egui/tests/it/actions_clear_all.rs diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index 5290299ae5d..eecc287c291 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -142,6 +142,26 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// Clear confirmed definitions and their own shortcut bindings, preserving the document. +fn clear_all(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.clearAll"; + not_playing(s, cmd)?; + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before clearing actions")); + } + if p.get("confirm").and_then(Value::as_bool) != Some(true) { + return Err(bad(cmd, "clearing all actions requires `confirm`: true")); + } + let count = s.actions.list.len(); + if count != 0 { + let keys: std::collections::HashSet<_> = s.actions.list.iter().map(|a| format!("{}{}", crate::actions_cmds::PLAY_SHORTCUT_PREFIX, a.name)).collect(); + s.edit_prefs(|prefs| prefs.shortcuts.retain(|key, _| !keys.contains(key))); + s.actions.list.clear(); + s.actions.touch(); + } + Ok(json!({"cleared": count})) +} + /// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without /// it, every step (all on unless all already are, like clicking the action's own toggle). fn dialog(s: &mut Session, p: &Value) -> Result { @@ -314,6 +334,16 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.clearAll", + label: "Clear All Actions", + menu: &[], + shortcut: None, + params: r##"{"confirm":true} → {cleared:count}. Removes every action and its function-key binding after explicit confirmation. Stop recording or playback first. Other shortcuts and the document are unchanged."##, + enabled: always, + run: clear_all, + journal: false, + }, CommandSpec { id: "actions.duplicate", label: "Duplicate", diff --git a/crates/engine/tests/it/actions_clear_all.rs b/crates/engine/tests/it/actions_clear_all.rs new file mode 100644 index 00000000000..c196b7b22f3 --- /dev/null +++ b/crates/engine/tests/it/actions_clear_all.rs @@ -0,0 +1,55 @@ +//! Confirmation clears definitions and their own shortcuts without editing the document. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::json; + +#[test] +fn confirmed_clear_is_atomic_for_definitions_shortcuts_and_recording() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + for (name, set) in [("A", "One"), ("B", "Two")] { + let mut a = Action::new(name, vec![]); + a.set = Some(set.into()); + s.actions.list.push(a); + } + s.prefs.edit(|p| { + for (id, key) in [("actions.play:A", "F5"), ("actions.play:B", "F6"), ("actions.play:Unloaded", "F7"), ("view.zoomIn", "Cmd+0")] { + p.shortcuts.insert(id.into(), key.into()); + } + }); + let definitions = s.actions.list.clone(); + let shortcuts = s.prefs().shortcuts.clone(); + let rev = s.actions.rev; + for params in [json!({}), json!({"confirm": false}), json!({"confirm": null}), json!({"confirm": "yes"})] { + assert!(s.execute("actions.clearAll", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, definitions); + assert_eq!(s.prefs().shortcuts, shortcuts); + assert_eq!(s.actions.rev, rev); + } + s.execute("actions.record", json!({"action": "A"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let recording = s.actions.recording; + assert!(s.execute("actions.clearAll", json!({"confirm": true})).is_err()); + assert_eq!(s.actions.list, definitions, "refusal does not flush pending recording"); + assert_eq!(s.actions.recording, recording); + s.execute("actions.stop", json!({})).unwrap(); + s.actions.list.push(Action::new("Try Clear", vec![("actions.clearAll".into(), json!({"confirm": true}))])); + let definitions = s.actions.list.clone(); + let result = s.execute("actions.play", json!({"action": "Try Clear"})).unwrap(); + assert!(result["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{result}"); + assert_eq!(s.actions.list, definitions); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.entries(); + let journal = s.journal.clone(); + assert_eq!(s.execute("actions.clearAll", json!({"confirm": true})).unwrap()["cleared"], 3); + assert!(s.actions.list.is_empty()); + assert!(!s.prefs().shortcuts.contains_key("actions.play:A")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:B")); + assert_eq!(s.prefs().shortcuts.get("actions.play:Unloaded"), shortcuts.get("actions.play:Unloaded")); + assert_eq!(s.prefs().shortcuts.get("view.zoomIn"), shortcuts.get("view.zoomIn")); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + let rev = s.actions.rev; + assert_eq!(s.execute("actions.clearAll", json!({"confirm": true})).unwrap()["cleared"], 0); + assert_eq!(s.actions.rev, rev); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7528a8c580c..ddfe294a941 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod actions_clear_all; mod actions_duplicate; mod affinity; mod artboard_export_names; diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 3c7b22dda0f..4da9a19b9ad 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -1023,6 +1023,7 @@ fn dialog_set(app: &mut PhotocraftApp, p: &Value) -> Outcome { // A continuation belongs to the dialog that paused it, even when another // action has a valid token. Replacing it would resume or cancel that action. Some(_) if field == crate::actions::RESUME => err("the paused action token is read-only"), + Some(_) if field == crate::actions::clear_all::REVISION => err("the confirmed action revision is read-only"), // A marker picks the handler OK runs: a dialog keeps the ones it was opened with. Some(d) if !d.fields.contains_key(field) && crate::dialogs::is_route_marker(field) => err(format!("`{field}` is set only by the dialog it belongs to")), Some(d) => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e1f57e10652..99b11c7ed86 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -296,6 +296,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::replace_prompt::owns(fields) => crate::replace_prompt::body(ui, fields), _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), + _ if crate::actions::clear_all::owns(fields) => crate::actions::clear_all::body(ui), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), _ if crate::mask_trash_prompt::owns(fields) => crate::mask_trash_prompt::body(ui, fields), _ if crate::rasterize_prompt::owns(fields) => crate::rasterize_prompt::body(ui, fields), @@ -845,6 +846,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::delete_layer_prompt::owns, crate::disk_change_prompt::owns, crate::actions::owns_insert_stop, + crate::actions::clear_all::owns, crate::new_layer_ui::owns, crate::new_group_ui::owns, crate::mask_trash_prompt::owns, @@ -1003,6 +1005,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result _ if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app), _ if crate::preset_group_prompt::owns(&d.fields) => crate::preset_group_prompt::confirm(app, &d.fields), _ if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), + _ if crate::actions::clear_all::owns(&d.fields) => crate::actions::clear_all::confirm(app, &d.fields), _ if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), _ if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), _ if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index f096ef49523..d0a7c899688 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -14,6 +14,8 @@ use crate::PhotocraftApp; use crate::icons; use crate::theme::Tokens; +pub(crate) mod clear_all; + /// Which row is selected and which rows are expanded. The action list itself is /// [`photocraft_engine::Session::actions`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -564,7 +566,7 @@ fn suspended(app: &PhotocraftApp) -> bool { /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate") && suspended(app) { + if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.clearAll") && suspended(app) { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -702,6 +704,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { act = Some("stop"); } ui.separator(); + if ui.add_enabled(any && !recording, egui::Button::new(tl!("Clear All Actions"))).clicked() { + act = Some("clearAll"); + } if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); } @@ -730,6 +735,7 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Ok(Value::Null) } "duplicate" => duplicate_selection(app), + "clearAll" => clear_all::open(app), "load" => load_dialog(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), diff --git a/crates/ui-egui/src/panels/actions/clear_all.rs b/crates/ui-egui/src/panels/actions/clear_all.rs new file mode 100644 index 00000000000..275beb4962a --- /dev/null +++ b/crates/ui-egui/src/panels/actions/clear_all.rs @@ -0,0 +1,53 @@ +//! Confirm clearing the action library against the revision the user reviewed. +use serde_json::{Map, Value, json}; + +use crate::{PhotocraftApp, state::DialogKind}; + +const COMMAND: &str = "actions.clearAll"; +const MARK: &str = "__clearActions"; +/// The library revision the warning applies to; control clients cannot replace it. +pub(crate) const REVISION: &str = "__clearActionsRevision"; + +/// Whether this is the fixed Clear All confirmation, rather than an arbitrary command form. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Open without changing definitions; the actual command is authorized before prompting. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + let params = json!({"confirm": true}); + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + if let Some(gate) = gate { + gate(COMMAND, ¶ms).map_err(|e| e.to_string())?; + } + if app.automation_input + && let Some(authorize) = app.services.automation_command.as_ref() + { + authorize(COMMAND, ¶ms)?; + } + let fields = json!({MARK: true, REVISION: app.session.actions.rev, "__command": COMMAND, "__label": "Clear All Actions"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Ok(json!({"dialog": dialog})) +} + +/// The warning is fixed; automation cannot change the meaning of the requested deletion. +pub(crate) fn body(ui: &mut egui::Ui) { + ui.label(tl!("Clear all actions? This cannot be undone.")); +} + +/// Reject a changed library before dispatch, then reset the panel selection after success. +pub(crate) fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + if fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { + return Err(tl!("Invalid Clear All Actions command").into()); + } + if fields.get(REVISION).and_then(Value::as_u64) != Some(app.session.actions.rev) { + return Err(tl!("The actions changed. Request Clear All Actions again.").into()); + } + let result = app.run(COMMAND, json!({"confirm": true}))?; + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + app.ui.actions.expanded.clear(); + app.ui.actions.renaming = None; + app.ui.actions.reveal_recording = None; + Ok(result) +} diff --git a/crates/ui-egui/tests/it/actions_clear_all.rs b/crates/ui-egui/tests/it/actions_clear_all.rs new file mode 100644 index 00000000000..a59eaeedce2 --- /dev/null +++ b/crates/ui-egui/tests/it/actions_clear_all.rs @@ -0,0 +1,110 @@ +//! Exercise the actual Clear All panel entry and its confirmation, including stale state. +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action}; +use photocraft_ui_egui::{PhotocraftApp, actions, control, dialogs}; +use serde_json::{Value, json}; + +/// Show the real panel menu and modal with a selected action from a two-set library. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + for (name, set) in [("A", "One"), ("B", "Two")] { + let mut a = Action::new(name, vec![("select.all".into(), json!({}))]); + a.set = Some(set.into()); + app.session.actions.list.push(a); + } + app.session.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + app.ui.actions.selected = Some(0); + app.ui.actions.selected_step = Some(0); + app.ui.actions.expanded = vec![true, true]; + app.ui.actions.collapsed_sets = vec!["Remember".into()]; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Click the menu entry, rather than bypass its authorization and opening logic. +fn open(h: &mut Harness<'static, PhotocraftApp>) -> u64 { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Clear All Actions").click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +#[test] +fn cancel_preserves_the_library_and_ok_clears_it_and_panel_selection() { + let mut h = harness(); + let definitions = h.state().session.actions.list.clone(); + let shortcuts = h.state().session.prefs().shortcuts.clone(); + let revision = h.state().session.active().unwrap().revision; + open(&mut h); + h.get_by_label("Clear all actions? This cannot be undone."); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, definitions); + assert_eq!(h.state().session.prefs().shortcuts, shortcuts); + assert_eq!(h.state().ui.actions.selected, Some(0)); + assert_eq!(h.state().ui.actions.selected_step, Some(0)); + assert_eq!(h.state().ui.actions.expanded, [true, true]); + open(&mut h); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + assert!(h.state().session.actions.list.is_empty()); + assert_eq!(h.state().ui.actions.selected, None); + assert_eq!(h.state().ui.actions.selected_step, None); + assert!(h.state().ui.actions.expanded.is_empty()); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Remember"]); + assert!(!h.state().session.prefs().shortcuts.contains_key("actions.play:A")); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} + +fn deny_clear(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "actions.clearAll" { Err(EngineError::Other("clear denied".into())) } else { Ok(()) } +} + +#[test] +fn stale_denied_or_retargeted_confirmations_never_clear_the_library() { + for mode in ["stale", "denied", "retargeted"] { + let mut h = harness(); + if mode == "denied" { + h.state_mut().session.authorize = Some(deny_clear); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Clear All Actions").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(h.state().session.actions.list.len(), 2); + h.state_mut().session.authorize = None; + } + let dialog = open(&mut h); + let context = h.ctx.clone(); + let original = h.state().ui.dialogs.last().unwrap().fields["__clearActionsRevision"].clone(); + let (request, _receiver) = control::ControlRequest::new("ui.dialog.set", json!({"dialog": dialog, "field": "__clearActionsRevision", "value": 12345})); + let control::Outcome::Done(result) = control::handle(h.state_mut(), &context, &request) else { panic!("expected an immediate field refusal") }; + assert_eq!(result["ok"], false); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__clearActionsRevision"], original); + match mode { + "stale" => { + h.state_mut().run("actions.rename", json!({"action": "A", "name": "Changed"})).unwrap(); + } + "denied" => h.state_mut().session.authorize = Some(deny_clear), + _ => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("select.all")); + } + } + let definitions = h.state().session.actions.list.clone(); + let shortcuts = h.state().session.prefs().shortcuts.clone(); + let revision = h.state().session.active().unwrap().revision; + assert!(dialogs::confirm(h.state_mut(), dialog).is_err(), "{mode}"); + assert_eq!(h.state().session.actions.list, definitions, "{mode}"); + assert_eq!(h.state().session.prefs().shortcuts, shortcuts, "{mode}"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "{mode}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 61be475d521..7e1fbbc4ff9 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod actions_clear_all; mod actions_duplicate; mod adjust_preview_gpu; mod arbitrary_rotation_preview; From ca11c144d987a0d11564367d17904d60005bdd3e Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 15:26:19 -0800 Subject: [PATCH 057/384] Document Actions integration controls --- crates/engine/tests/it/actions_clear_all.rs | 1 + crates/engine/tests/it/actions_duplicate.rs | 3 +++ crates/engine/tests/it/atn_menu_item.rs | 3 +++ crates/ui-egui/tests/it/actions_clear_all.rs | 3 +++ crates/ui-egui/tests/it/actions_duplicate.rs | 2 ++ crates/ui-egui/tests/it/atn_menu_item.rs | 6 ++++++ 6 files changed, 18 insertions(+) diff --git a/crates/engine/tests/it/actions_clear_all.rs b/crates/engine/tests/it/actions_clear_all.rs index c196b7b22f3..5b6bafd0fca 100644 --- a/crates/engine/tests/it/actions_clear_all.rs +++ b/crates/engine/tests/it/actions_clear_all.rs @@ -2,6 +2,7 @@ use photocraft_engine::{Session, actions_cmds::Action}; use serde_json::json; +/// Clearing requires consent and idle playback/recording, and preserves document history. #[test] fn confirmed_clear_is_atomic_for_definitions_shortcuts_and_recording() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs index e5c97d294d3..31907b7ceda 100644 --- a/crates/engine/tests/it/actions_duplicate.rs +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -9,6 +9,7 @@ fn session() -> Session { s } +/// Copies retain metadata and call targets without taking the original function key. #[test] fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { let mut s = session(); @@ -62,6 +63,7 @@ fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() assert_eq!(s.actions.list[1].steps[0].1["action"], 3); } +/// Step copies own their settings and retain both flag columns after insertion. #[test] fn step_copies_preserve_payloads_and_shift_both_flag_columns() { let mut s = session(); @@ -87,6 +89,7 @@ fn step_copies_preserve_payloads_and_shift_both_flag_columns() { assert_eq!(s.active().unwrap().revision, revision); } +/// Refusals preserve pending recording; valid step copies keep recording usable. #[test] fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() { let mut s = session(); diff --git a/crates/engine/tests/it/atn_menu_item.rs b/crates/engine/tests/it/atn_menu_item.rs index d0f5c108e83..ee914cf41e4 100644 --- a/crates/engine/tests/it/atn_menu_item.rs +++ b/crates/engine/tests/it/atn_menu_item.rs @@ -6,6 +6,7 @@ use photocraft_psd::{ }; use serde_json::json; +/// Build a single-step ATN input independently of the engine's exporter. fn file(item: AtnItem) -> Vec { atn::write(&AtnFile { name: UnicodeString::new_nul("Menu"), @@ -16,6 +17,7 @@ fn file(item: AtnItem) -> Vec { .unwrap() } +/// Absent settings retain menu intent and event kind, and refuse headless edits. #[test] fn absent_image_size_settings_round_trip_and_require_interaction_without_edits() { for (event, kind) in [(AtnEvent::Code(*b"ImgS"), "code"), (AtnEvent::name("imageSize"), "name")] { @@ -44,6 +46,7 @@ fn absent_image_size_settings_round_trip_and_require_interaction_without_edits() } } +/// Empty and target descriptors, other events and recorded settings keep their meaning. #[test] fn empty_target_and_other_event_forms_keep_their_existing_meaning() { let empty = Descriptor::new("null"); diff --git a/crates/ui-egui/tests/it/actions_clear_all.rs b/crates/ui-egui/tests/it/actions_clear_all.rs index a59eaeedce2..46476135dc5 100644 --- a/crates/ui-egui/tests/it/actions_clear_all.rs +++ b/crates/ui-egui/tests/it/actions_clear_all.rs @@ -39,6 +39,7 @@ fn open(h: &mut Harness<'static, PhotocraftApp>) -> u64 { h.state().ui.dialogs.last().unwrap().id } +/// Cancel preserves the library and selection; OK clears only the intended state. #[test] fn cancel_preserves_the_library_and_ok_clears_it_and_panel_selection() { let mut h = harness(); @@ -67,10 +68,12 @@ fn cancel_preserves_the_library_and_ok_clears_it_and_panel_selection() { assert_eq!(h.state().session.active().unwrap().revision, revision); } +/// Deny the concrete destructive command at either authorization boundary. fn deny_clear(id: &str, _: &Value) -> photocraft_engine::Result<()> { if id == "actions.clearAll" { Err(EngineError::Other("clear denied".into())) } else { Ok(()) } } +/// Stale state, denied authority and a changed command cannot redirect confirmation. #[test] fn stale_denied_or_retargeted_confirmations_never_clear_the_library() { for mode in ["stale", "denied", "retargeted"] { diff --git a/crates/ui-egui/tests/it/actions_duplicate.rs b/crates/ui-egui/tests/it/actions_duplicate.rs index a9f9aeb206b..f577e4a2c06 100644 --- a/crates/ui-egui/tests/it/actions_duplicate.rs +++ b/crates/ui-egui/tests/it/actions_duplicate.rs @@ -4,6 +4,7 @@ use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use photocraft_ui_egui::{PhotocraftApp, actions, dialogs}; use serde_json::json; +/// Real panel clicks select each new action or step and preserve its flag columns. #[test] fn duplicate_menu_selects_the_new_action_and_then_the_new_step() { let mut app = PhotocraftApp::new(Session::new(), Default::default()); @@ -34,6 +35,7 @@ fn duplicate_menu_selects_the_new_action_and_then_the_new_step() { assert_eq!(copied.dialogs.iter().copied().collect::>(), [0, 1]); } +/// Duplication cannot shift a recording owner held by a paused action dialog. #[test] fn duplicate_cannot_shift_the_owner_of_a_suspended_recording() { let mut app = PhotocraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/tests/it/atn_menu_item.rs b/crates/ui-egui/tests/it/atn_menu_item.rs index 4f02ec26735..d87c92323d9 100644 --- a/crates/ui-egui/tests/it/atn_menu_item.rs +++ b/crates/ui-egui/tests/it/atn_menu_item.rs @@ -4,6 +4,7 @@ use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; use photocraft_ui_egui::{PhotocraftApp, dialogs}; use serde_json::{Value, json}; +/// Import a menu step and append a selection step to expose continuation ordering. fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); @@ -14,6 +15,7 @@ fn app() -> PhotocraftApp { app } +/// The real sizing dialog cancels without edits and resumes only after confirmation. #[test] fn image_size_menu_item_opens_real_dialog_cancel_stops_and_confirm_resumes() { let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state(|ui, app: &mut PhotocraftApp| dialogs::show(app, ui.ctx()), app()); @@ -50,10 +52,12 @@ fn image_size_menu_item_opens_real_dialog_cancel_stops_and_confirm_resumes() { assert_eq!(h.state().session.actions.list[0].steps, definition, "menu playback does not record the entered settings into the definition"); } +/// Permit the wrapper while denying the concrete resize command it would open. fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> { if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) } } +/// Authorization of the actual menu command precedes any dialog or mutation. #[test] fn menu_item_gates_its_actual_command_before_opening_a_dialog() { let mut app = app(); @@ -68,10 +72,12 @@ fn menu_item_gates_its_actual_command_before_opening_a_dialog() { assert!(app.session.active().unwrap().doc.selection.is_none()); } +/// Allow opening without settings, but deny the settings entered at confirmation. fn deny_changed_width(id: &str, p: &Value) -> photocraft_engine::Result<()> { if id == "image.imageSize" && p.get("width").is_some() { Err(EngineError::Other("entered resize denied".into())) } else { Ok(()) } } +/// Final dialog settings remain subject to the gate that began playback. #[test] fn menu_item_confirmation_keeps_the_playback_gate_after_the_request_ends() { let mut app = app(); From 5faeb4ab24206ebe13128ae629b0760711b4f3cd Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 16:15:49 -0800 Subject: [PATCH 058/384] Confirm whole-library Actions replacement before importing --- crates/automation/src/workspace.rs | 1 + crates/automation/src/workspace/tests.rs | 4 + crates/engine/src/cmd/atn_cmds.rs | 12 +- crates/engine/src/cmd/atn_cmds/files.rs | 40 ++++- crates/engine/tests/it/actions_replace.rs | 106 +++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/dialogs.rs | 6 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/actions.rs | 7 +- crates/ui-egui/src/panels/actions/replace.rs | 123 +++++++++++++ crates/ui-egui/tests/it/actions_replace.rs | 175 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 13 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/it/actions_replace.rs create mode 100644 crates/ui-egui/src/panels/actions/replace.rs create mode 100644 crates/ui-egui/tests/it/actions_replace.rs diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index a60cc66a16f..8c5f3efcda0 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -465,6 +465,7 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { "actions.load" + | "actions.replace" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index e4c710f2819..7783825d38a 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -435,6 +435,7 @@ fn apply_data_set_is_judged_by_the_values_it_applies() { assert!(!visible(&headless)); } +/// Path-reading commands are refused before access; inline data keeps its precedence. #[test] fn automation_rejects_ambient_path_commands_and_preferences() { for (id, params) in [ @@ -448,6 +449,7 @@ fn automation_rejects_ambient_path_commands_and_preferences() { ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), + ("actions.replace", serde_json::json!({"path": "/outside/set.atn", "confirm": true})), ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), @@ -465,6 +467,8 @@ fn automation_rejects_ambient_path_commands_and_preferences() { ("swatches.import", serde_json::json!({"data": "QUJD"})), ("swatches.export", serde_json::json!({"format": "ase"})), ("actions.load", serde_json::json!({"data": "QUJD"})), + ("actions.replace", serde_json::json!({"data": "QUJD", "confirm": true})), + ("actions.replace", serde_json::json!({"data": "QUJD", "path": "/outside/unused.atn", "confirm": true})), ("actions.save", serde_json::json!({"set": "S"})), ("style.presets.importAsl", serde_json::json!({"data": "QUJD"})), ("style.presets.exportAsl", serde_json::json!({"group": "Basics"})), diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index a7e38f93006..5dbb886383f 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -55,7 +55,7 @@ use descriptors::{jbool, jstr}; use events::{EVENTS, event_and_descriptor, event_for}; #[cfg(test)] use fields::Mapped; -use files::{load, save}; +use files::{load, replace, save}; pub use menu_item::{MENU_ITEM, menu_item_command}; #[cfg(test)] use steps_doc::crop_from; @@ -110,6 +110,16 @@ pub fn specs() -> Vec { run: load, journal: false, }, + CommandSpec { + id: "actions.replace", + label: "Replace Actions…", + menu: &[], + shortcut: None, + params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name?,"confirm":true} → {set,actions:[names],steps,unsupported,functionKeys,warnings}. Parse and validate the file before replacing the entire action library and its function-key bindings. Requires explicit confirmation and stopped recording/playback. Other shortcuts and the document are unchanged."##, + enabled: always, + run: replace, + journal: false, + }, CommandSpec { id: "actions.save", label: "Save Actions…", diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index be7bd451519..e9001da11bb 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -1,8 +1,25 @@ use super::convert::{UniqueNames, export, import}; use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json}; +/// Load with the existing append or same-named-set replacement semantics. pub(super) fn load(s: &mut Session, p: &Value) -> Result { - let cmd = "actions.load"; + import_actions(s, p, false) +} + +/// Whole-library replacement requires explicit consent before reading or changing anything. +pub(super) fn replace(s: &mut Session, p: &Value) -> Result { + if p.get("confirm").and_then(Value::as_bool) != Some(true) { + return Err(bad("actions.replace", "replacing all actions requires `confirm`: true")); + } + if p.get("replace").is_some() { + return Err(bad("actions.replace", "use `confirm`: true; `replace` belongs to actions.load's same-set mode")); + } + import_actions(s, p, true) +} + +/// Prepare names, references and shortcuts before committing either import mode. +fn import_actions(s: &mut Session, p: &Value, whole: bool) -> Result { + let cmd = if whole { "actions.replace" } else { "actions.load" }; if s.actions.recording.is_some() || s.actions.playing > 0 { return Err(bad(cmd, "stop recording or playing before loading actions")); } @@ -23,10 +40,8 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { Some(Value::Bool(b)) => *b, Some(_) => return Err(bad(cmd, "`replace` must be true or false")), }; - if replace { - s.actions.list.retain(|a| a.set.as_deref() != Some(imp.set.as_str())); - } - let mut taken = UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + let retained = s.actions.list.iter().filter(|a| !whole && (!replace || a.set.as_deref() != Some(imp.set.as_str()))); + let mut taken = UniqueNames::new(retained.map(|a| a.name.clone())); let mut names = Vec::with_capacity(imp.actions.len()); // The file's name of each action that had to be renamed, and its new name. let mut renamed: std::collections::HashMap = std::collections::HashMap::new(); @@ -58,10 +73,21 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { } } } - s.actions.list.extend(added); let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); - if !keys.is_empty() { + let removed_keys: std::collections::HashSet<_> = + if whole { s.actions.list.iter().map(|a| format!("{}{}", crate::actions_cmds::PLAY_SHORTCUT_PREFIX, a.name)).collect() } else { Default::default() }; + // Everything that can reject the input has completed. No callbacks or I/O split this commit. + if whole { + s.actions.list.clear(); + } else if replace { + s.actions.list.retain(|a| a.set.as_deref() != Some(imp.set.as_str())); + } + s.actions.list.extend(added); + if !keys.is_empty() || !removed_keys.is_empty() { s.edit_prefs(|prefs| { + if !removed_keys.is_empty() { + prefs.shortcuts.retain(|key, _| !removed_keys.contains(key)); + } for (name, key) in &keys { // A function key belongs to one action. prefs.shortcuts.retain(|k, v| !(k.starts_with(crate::actions_cmds::PLAY_SHORTCUT_PREFIX) && v == key)); diff --git a/crates/engine/tests/it/actions_replace.rs b/crates/engine/tests/it/actions_replace.rs new file mode 100644 index 00000000000..12ee95a26e8 --- /dev/null +++ b/crates/engine/tests/it/actions_replace.rs @@ -0,0 +1,106 @@ +//! Whole-library replacement shares import semantics without clearing before validation. +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use serde_json::{Value, json}; + +/// A caller, its named target and an action sharing an old name expose reference/key ownership. +fn input() -> Vec { + let mut caller = Action::new("Caller", vec![("actions.play".into(), json!({"action": "NewChild"})), ("select.deselect".into(), json!({}))]); + caller.disabled.insert(1); + caller.dialogs.insert(0); + let child = Action::new("NewChild", vec![("select.all".into(), json!({}))]); + let reused = Action::new("OldA", vec![("select.deselect".into(), json!({}))]); + atn_cmds::export("Incoming", &[&caller, &child, &reused], &[("NewChild".into(), "F8".into())], false).unwrap().0 +} + +/// Two old sets and owned/unloaded/global bindings distinguish whole from same-set replacement. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + for (name, set) in [("OldA", "Incoming"), ("OldB", "Other")] { + let mut a = Action::new(name, vec![("select.deselect".into(), json!({}))]); + a.set = Some(set.into()); + s.actions.list.push(a); + } + s.prefs.edit(|p| { + for (id, key) in [("actions.play:OldA", "F5"), ("actions.play:OldB", "F6"), ("actions.play:Unloaded", "F9"), ("view.zoomIn", "Cmd+0")] { + p.shortcuts.insert(id.into(), key.into()); + } + }); + s +} + +/// Same-set Load keeps other sets; confirmed Replace swaps all definitions and their keys. +#[test] +fn replace_preserves_imported_flags_references_and_keys_without_inheriting_old_bindings() { + let data = photocraft_engine::paint::tile::b64_encode(&input()); + let mut loaded = session(); + loaded.execute("actions.load", json!({"data": data})).unwrap(); + assert!(loaded.actions.list.iter().any(|a| a.name == "OldB"), "ordinary Load still replaces only the matching set"); + let mut s = session(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.entries(); + let journal = s.journal.clone(); + let result = s.execute("actions.replace", json!({"data": data, "confirm": true})).unwrap(); + assert_eq!(result["actions"], json!(["Caller", "NewChild", "OldA"])); + assert_eq!(s.actions.list.len(), 3); + assert!(s.actions.list.iter().all(|a| a.set.as_deref() == Some("Incoming"))); + assert_eq!(s.actions.list[0].disabled.iter().copied().collect::>(), [1]); + assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); + assert!(!s.prefs().shortcuts.contains_key("actions.play:OldA"), "an identically named replacement does not inherit the old function key"); + assert!(!s.prefs().shortcuts.contains_key("actions.play:OldB")); + assert_eq!(s.prefs().shortcuts["actions.play:NewChild"], "F8"); + assert_eq!(s.prefs().shortcuts["actions.play:Unloaded"], "F9"); + assert_eq!(s.prefs().shortcuts["view.zoomIn"], "Cmd+0"); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + let played = s.execute("actions.play", json!({"action": "Caller"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.is_some(), "the imported named call still targets NewChild"); + let empty = atn_cmds::export("Empty", &[], &[], false).unwrap().0; + let result = s.execute("actions.replace", json!({"data": photocraft_engine::paint::tile::b64_encode(&empty), "confirm": true})).unwrap(); + assert_eq!(result["set"], "Empty"); + assert!(s.actions.list.is_empty()); + assert!(!s.prefs().shortcuts.contains_key("actions.play:NewChild")); +} + +/// Rejected input, absent consent and active recording/playback never clear or flush definitions. +#[test] +fn invalid_or_unconfirmed_replacement_preserves_the_library_and_preferences() { + let data = photocraft_engine::paint::tile::b64_encode(&input()); + let mut s = session(); + let list = s.actions.list.clone(); + let shortcuts = s.prefs().shortcuts.clone(); + let revision = s.active().unwrap().revision; + let rev = s.actions.rev; + for params in [ + json!({"data": data}), + json!({"data": data, "confirm": false}), + json!({"data": data, "confirm": "yes"}), + json!({"data": data, "confirm": true, "replace": false}), + json!({"data": "!invalid!", "confirm": true}), + json!({"data": "QUJD", "confirm": true}), + json!({"confirm": true}), + json!({"data": data, "confirm": true, "set": " "}), + json!({"data": data, "confirm": true, "set": 7}), + ] { + assert!(s.execute("actions.replace", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, list); + assert_eq!(s.prefs().shortcuts, shortcuts); + assert_eq!(s.actions.rev, rev); + assert_eq!(s.active().unwrap().revision, revision); + } + s.execute("actions.record", json!({"action": "OldA"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let recording = s.actions.recording; + assert!(s.execute("actions.replace", json!({"data": data, "confirm": true})).is_err()); + assert_eq!(s.actions.list, list, "pending recorded steps were not flushed"); + assert_eq!(s.actions.recording, recording); + s.execute("actions.stop", json!({})).unwrap(); + s.actions.list.push(Action::new("Try Replace", vec![("actions.replace".into(), json!({"data": data, "confirm": true}))])); + let list = s.actions.list.clone(); + let result: Value = s.execute("actions.play", json!({"action": "Try Replace"})).unwrap(); + assert!(result["failed"].is_object(), "{result}"); + assert_eq!(s.actions.list, list); + assert_eq!(s.prefs().shortcuts, shortcuts); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index ddfe294a941..f4292718450 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod actions_clear_all; mod actions_duplicate; +mod actions_replace; mod affinity; mod artboard_export_names; mod atn_crystallize_cell_size; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 14038b10de4..ac62672c132 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -193,6 +193,7 @@ impl PhotocraftApp { dds_options: None, held_opens: Default::default(), held_preset_import: None, + held_actions_replace: None, allow_close: false, stylus: Default::default(), control_click: Default::default(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 99b11c7ed86..4448f86f2c1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -297,6 +297,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), _ if crate::actions::clear_all::owns(fields) => crate::actions::clear_all::body(ui), + _ if crate::actions::replace::owns(fields) => crate::actions::replace::body(app, ui, d.id), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), _ if crate::mask_trash_prompt::owns(fields) => crate::mask_trash_prompt::body(ui, fields), _ if crate::rasterize_prompt::owns(fields) => crate::rasterize_prompt::body(ui, fields), @@ -847,6 +848,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::disk_change_prompt::owns, crate::actions::owns_insert_stop, crate::actions::clear_all::owns, + crate::actions::replace::owns, crate::new_layer_ui::owns, crate::new_group_ui::owns, crate::mask_trash_prompt::owns, @@ -1006,6 +1008,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result _ if crate::preset_group_prompt::owns(&d.fields) => crate::preset_group_prompt::confirm(app, &d.fields), _ if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), _ if crate::actions::clear_all::owns(&d.fields) => crate::actions::clear_all::confirm(app, &d.fields), + _ if crate::actions::replace::owns(&d.fields) => crate::actions::replace::confirm(app, d.id, &d.fields), _ if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), _ if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), _ if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), @@ -1057,6 +1060,9 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { let closed = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; crate::actions::stopped(app, &closed.fields); + if crate::actions::replace::owns(&closed.fields) { + crate::actions::replace::cancel(app, id); + } if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e17b2bc80dd..4f675985316 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -689,6 +689,8 @@ pub struct PhotocraftApp { pub(crate) held_opens: std::collections::VecDeque, /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). pub(crate) held_preset_import: Option, + /// The exact Actions input awaiting its own replacement confirmation. + pub(crate) held_actions_replace: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index d0a7c899688..48a336431a9 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -15,6 +15,7 @@ use crate::icons; use crate::theme::Tokens; pub(crate) mod clear_all; +pub(crate) mod replace; /// Which row is selected and which rows are expanded. The action list itself is /// [`photocraft_engine::Session::actions`]. @@ -566,7 +567,7 @@ fn suspended(app: &PhotocraftApp) -> bool { /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.clearAll") && suspended(app) { + if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.clearAll" | "actions.replace") && suspended(app) { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -710,6 +711,9 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); } + if ui.add_enabled(!recording, egui::Button::new(tl!("Replace Actions…"))).clicked() { + act = Some("replace"); + } if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { act = Some("save"); } @@ -737,6 +741,7 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { "duplicate" => duplicate_selection(app), "clearAll" => clear_all::open(app), "load" => load_dialog(app), + "replace" => replace::open(app), "stop" => insert_stop_dialog(app), _ => save_dialog(app, None), }; diff --git a/crates/ui-egui/src/panels/actions/replace.rs b/crates/ui-egui/src/panels/actions/replace.rs new file mode 100644 index 00000000000..9d12c0f4015 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/replace.rs @@ -0,0 +1,123 @@ +//! A picked Actions file belongs to one confirmation and never gets reread after consent. +use serde_json::{Map, Value, json}; + +use crate::{PhotocraftApp, state::DialogKind}; + +const COMMAND: &str = "actions.replace"; +const MARK: &str = "__replaceActions"; + +/// Frozen input and authority, kept out of editable and inspectable dialog fields. +pub(crate) struct Held { + dialog: u64, + revision: u64, + params: Value, + gate: Option, + automated: bool, + file: String, + count: usize, +} + +/// Identify the replacement handler through the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Apply both the request's authority and the current confirmation's authority. +fn authorize(app: &PhotocraftApp, params: &Value, gate: Option, automated: bool) -> Result<(), String> { + if let Some(gate) = gate { + gate(COMMAND, params).map_err(|e| e.to_string())?; + } + if let Some(current) = app.session.authorize { + current(COMMAND, params).map_err(|e| e.to_string())?; + } + if (automated || app.automation_input || app.session.authorize.is_some()) + && let Some(check) = app.services.automation_command.as_ref() + { + check(COMMAND, params)?; + } + Ok(()) +} + +/// A file picker or warning must not discard an active or suspended recording. +fn idle(app: &PhotocraftApp) -> Result<(), String> { + super::suspended_guard(app, COMMAND)?; + if app.session.actions.recording.is_some() || app.session.actions.playing > 0 { + return Err(tl!("Stop recording or playback before replacing actions.").into()); + } + Ok(()) +} + +/// Pick a single file without changing Load Actions' separate bulk-import behavior. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + idle(app)?; + if app.held_actions_replace.is_some() { + return Err(tl!("Confirm or cancel the pending Actions replacement first.").into()); + } + let revision = app.session.actions.rev; + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let automated = app.automation_input || gate.is_some(); + authorize(app, &json!({"confirm": true}), gate, automated)?; + app.pick_file_bytes_filtered(&["atn"], move |app, name, bytes| { + idle(app)?; + if app.session.actions.rev != revision || app.held_actions_replace.is_some() { + return Err(tl!("The actions changed. Request Replace Actions again.").into()); + } + if bytes.len() as u64 > photocraft_engine::preset_import_cmds::MAX_FILE_BYTES { + return Err(tl!("The Actions file is too large.").into()); + } + // Validate before asking to discard the library. The normal command parses this + // same immutable input again on confirmation, keeping dispatch authoritative. + let count = photocraft_engine::atn_cmds::import(&bytes)?.actions.len(); + let params = json!({"data": photocraft_engine::paint::tile::b64_encode(&bytes), "confirm": true}); + drop(bytes); + authorize(app, ¶ms, gate, automated)?; + let fields = json!({MARK: true, "__command": COMMAND, "__label": "Replace Actions"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_actions_replace = Some(Held { dialog, revision, params, gate, automated, file: crate::file_open::display_name(&name), count }); + Ok(json!({"dialog": dialog, "confirmationPending": true})) + }) +} + +/// Show the actual held file and action count, never metadata supplied by dialog edits. +pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, dialog: u64) { + if let Some(held) = app.held_actions_replace.as_ref().filter(|held| held.dialog == dialog) { + let text = crate::i18n::fmt(tl!("Replace all actions with {count} actions from {file}?"), &[("count", &held.count.to_string()), ("file", &held.file)]); + ui.label(text); + ui.label(tl!("This removes the current action library and cannot be undone.")); + } +} + +/// Only this warning may consume its input; every refusal releases that owned input. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_actions_replace.as_ref().is_none_or(|held| held.dialog != dialog) { + return Err(tl!("No Actions replacement belongs to this dialog.").into()); + } + let held = app.held_actions_replace.take().ok_or(tl!("No Actions replacement is waiting."))?; + if fields.get("__command").and_then(Value::as_str) != Some(COMMAND) || app.session.actions.rev != held.revision { + return Err(tl!("The actions or replacement command changed. Request Replace Actions again.").into()); + } + idle(app)?; + authorize(app, &held.params, held.gate, held.automated)?; + let saved = app.session.authorize; + app.session.authorize = saved.or(held.gate); + let result = app.run(COMMAND, held.params); + app.session.authorize = saved; + let result = result?; + app.ui.actions.selected = (!app.session.actions.list.is_empty()).then_some(0); + app.ui.actions.selected_step = None; + app.ui.actions.expanded = vec![false; app.session.actions.list.len()]; + if let Some(first) = app.ui.actions.expanded.first_mut() { + *first = true; + } + app.ui.actions.renaming = None; + app.ui.actions.reveal_recording = None; + app.ui.panels.history = true; + Ok(result) +} + +/// Cancelling another dialog cannot discard this warning's staged replacement. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_actions_replace.as_ref().is_some_and(|held| held.dialog == dialog) { + app.held_actions_replace = None; + } +} diff --git a/crates/ui-egui/tests/it/actions_replace.rs b/crates/ui-egui/tests/it/actions_replace.rs new file mode 100644 index 00000000000..a969da46d4e --- /dev/null +++ b/crates/ui-egui/tests/it/actions_replace.rs @@ -0,0 +1,175 @@ +//! Replace Actions exercises the real asynchronous picker and its owned confirmation. +use std::{ + cell::RefCell, + rc::Rc, + sync::atomic::{AtomicBool, Ordering}, +}; + +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; +use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, PhotocraftApp, Services, actions, dialogs, state::DialogKind}; +use serde_json::{Value, json}; + +type Picker = Rc>>; + +/// Retain actual picker replies while drawing the menu, picker polling and confirmation. +fn harness() -> (Harness<'static, PhotocraftApp>, Picker) { + let picker: Picker = Default::default(); + let held = picker.clone(); + let services = Services { file_dialog: Some(Box::new(move |request, _, reply| held.borrow_mut().push((request, reply)))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + for (name, set) in [("A", "One"), ("B", "Two")] { + let mut action = Action::new(name, vec![("select.deselect".into(), json!({}))]); + action.set = Some(set.into()); + app.session.actions.list.push(action); + } + app.session.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + app.ui.actions.selected = Some(1); + app.ui.actions.selected_step = Some(0); + app.ui.actions.expanded = vec![true, true]; + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + app.poll_file_dialog(ui.ctx(), None); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + (h, picker) +} + +/// Encode a small valid file with visibly different action and set names. +fn input() -> Vec { + let action = Action::new("New", vec![("select.all".into(), json!({}))]); + atn_cmds::export("Incoming", &[&action], &[], false).unwrap().0 +} + +/// Open through the menu and verify that Replace does not use Load's multi-file picker. +fn pick(h: &mut Harness<'static, PhotocraftApp>, picker: &Picker) { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Replace Actions…").click(); + h.run_steps(3); + assert!(matches!(picker.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(ext), .. }, _)] if ext == &["atn"])); +} + +/// Deliver a platform answer through the real continuation, without bypassing staging. +fn answer(h: &mut Harness<'static, PhotocraftApp>, picker: &Picker, answer: Option) { + picker.borrow_mut().pop().unwrap().1.send(answer); + h.run_steps(3); +} + +/// Stage a valid inline file and return its actual warning identity. +fn stage(h: &mut Harness<'static, PhotocraftApp>, picker: &Picker) -> u64 { + pick(h, picker); + answer(h, picker, Some(FileDialogAnswer::Contents("Chosen.atn".into(), input()))); + let dialog = h.state().ui.dialogs.last().unwrap(); + assert_eq!(dialog.fields["__replaceActions"], true); + assert!(!dialog.fields.contains_key("data")); + dialog.id +} + +/// Both cancellations and malformed input preserve state; OK uses the bytes already picked. +#[test] +fn cancelling_preserves_actions_and_confirmation_never_rereads_the_source_path() { + let (mut h, picker) = harness(); + let actions = h.state().session.actions.list.clone(); + let shortcuts = h.state().session.prefs().shortcuts.clone(); + let revision = h.state().session.active().unwrap().revision; + pick(&mut h, &picker); + answer(&mut h, &picker, None); + assert!(h.state().ui.dialogs.is_empty()); + pick(&mut h, &picker); + answer(&mut h, &picker, Some(FileDialogAnswer::Contents("Broken.atn".into(), b"bad".to_vec()))); + assert!(h.state().ui.dialogs.is_empty()); + stage(&mut h, &picker); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, actions); + assert_eq!(h.state().session.prefs().shortcuts, shortcuts); + assert_eq!(h.state().ui.actions.selected, Some(1)); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Picked.atn"); + std::fs::write(&path, input()).unwrap(); + pick(&mut h, &picker); + answer(&mut h, &picker, Some(FileDialogAnswer::Paths(vec![path.to_str().unwrap().into()]))); + assert!(h.state().ui.dialogs.last().unwrap().fields.contains_key("__replaceActions")); + std::fs::write(&path, b"changed after the warning").unwrap(); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(h.state().session.actions.list.len(), 1); + assert_eq!(h.state().session.actions.list[0].name, "New"); + assert_eq!(h.state().ui.actions.selected, Some(0)); + assert_eq!(h.state().ui.actions.selected_step, None); + assert_eq!(h.state().ui.actions.expanded, [true]); + assert!(!h.state().session.prefs().shortcuts.contains_key("actions.play:A")); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} + +static DENIED: AtomicBool = AtomicBool::new(false); + +/// Model a retained caller whose authorization can be revoked while its warning is open. +fn retained_gate(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "actions.replace" && DENIED.load(Ordering::SeqCst) { Err(EngineError::Other("replace denied".into())) } else { Ok(()) } +} + +/// Stale input, revoked retained authority and retargeting all refuse without consuming other dialogs. +#[test] +fn stale_denied_and_retargeted_confirmations_preserve_the_library() { + for mode in ["picker-stale", "warning-stale", "denied", "retargeted"] { + DENIED.store(false, Ordering::SeqCst); + let (mut h, picker) = harness(); + if mode == "denied" { + h.state_mut().session.authorize = Some(retained_gate); + } + if mode == "picker-stale" { + pick(&mut h, &picker); + h.state_mut().run("actions.rename", json!({"action": "A", "name": "Changed"})).unwrap(); + answer(&mut h, &picker, Some(FileDialogAnswer::Contents("Chosen.atn".into(), input()))); + assert!(h.state().ui.dialogs.is_empty()); + } else { + let dialog = stage(&mut h, &picker); + match mode { + "warning-stale" => { + h.state_mut().run("actions.rename", json!({"action": "A", "name": "Changed"})).unwrap(); + } + "denied" => { + h.state_mut().session.authorize = None; + DENIED.store(true, Ordering::SeqCst); + } + _ => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("select.all")); + } + } + let before = h.state().session.actions.list.clone(); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err(), "{mode}"); + assert_eq!(h.state().session.actions.list, before, "{mode}"); + } + assert_eq!(h.state().session.actions.list.len(), 2, "{mode}"); + assert_eq!(h.state().session.prefs().shortcuts.get("actions.play:New"), None); + DENIED.store(false, Ordering::SeqCst); + let retry = stage(&mut h, &picker); + dialogs::cancel(h.state_mut(), retry).unwrap(); + h.run_steps(3); + } +} + +/// A forged warning cannot confirm or cancel the real warning's privately held bytes. +#[test] +fn another_dialog_cannot_consume_the_pending_replacement() { + let (mut h, picker) = harness(); + let original = stage(&mut h, &picker); + let fields = h.state().ui.dialogs.last().unwrap().fields.clone(); + let impostor = h.state_mut().ui.open_dialog(DialogKind::Command, fields.clone()); + assert!(dialogs::confirm(h.state_mut(), impostor).is_err()); + let impostor = h.state_mut().ui.open_dialog(DialogKind::Command, fields); + dialogs::cancel(h.state_mut(), impostor).unwrap(); + assert_eq!(h.state().session.actions.list.len(), 2); + dialogs::confirm(h.state_mut(), original).unwrap(); + assert_eq!(h.state().session.actions.list[0].name, "New"); + assert_eq!(h.state().session.actions.list.len(), 1); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 7e1fbbc4ff9..b06a9094d2f 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -2,6 +2,7 @@ mod actions_clear_all; mod actions_duplicate; +mod actions_replace; mod adjust_preview_gpu; mod arbitrary_rotation_preview; mod atn_menu_item; From daeef72f9368b7f2764ead0509e0294916a1607f Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 18:18:47 -0800 Subject: [PATCH 059/384] Add Actions panel recording and library commands Add Record Again, set duplication, opt-in tool recording, menu insertion, native path snapshots and placeholder Reset. Retain dialog permissions and editor ownership through previews and displacement-map selection. Keep Path ATN export unsupported pending reference data and label the empty default set as a placeholder. --- crates/engine/src/cmd/actions_cmds.rs | 33 ++- crates/engine/src/cmd/actions_edit_cmds.rs | 168 +++++++++++++- .../src/cmd/actions_edit_cmds/insert_path.rs | 26 +++ .../src/cmd/actions_edit_cmds/record_again.rs | 55 +++++ crates/engine/src/cmd/atn_cmds.rs | 4 +- crates/engine/src/cmd/atn_cmds/menu_item.rs | 58 +++-- crates/engine/src/cmd/atn_cmds/tests.rs | 15 +- crates/engine/src/jobs.rs | 3 +- crates/engine/tests/it/actions_panel_menu.rs | 184 +++++++++++++++ .../engine/tests/it/actions_record_again.rs | 77 +++++++ crates/engine/tests/it/atn_menu_item.rs | 1 + crates/engine/tests/it/main.rs | 2 + crates/ui-egui/src/app.rs | 4 + crates/ui-egui/src/canvas/filter_preview.rs | 1 + crates/ui-egui/src/dialogs.rs | 34 ++- crates/ui-egui/src/dialogs/adjust_preview.rs | 4 +- crates/ui-egui/src/dialogs/filter_dialog.rs | 4 +- crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/panels/actions.rs | 207 +++++++++++++++-- .../ui-egui/src/panels/actions/clear_all.rs | 45 +++- .../ui-egui/src/panels/actions/menu_item.rs | 82 +++++++ .../src/panels/actions/record_again.rs | 132 +++++++++++ crates/ui-egui/src/panels/actions/replace.rs | 13 +- crates/ui-egui/src/panels/actions/reset.rs | 95 ++++++++ crates/ui-egui/tests/it/actions_panel_menu.rs | 217 ++++++++++++++++++ .../ui-egui/tests/it/actions_record_again.rs | 197 ++++++++++++++++ crates/ui-egui/tests/it/main.rs | 2 + docs/development.md | 24 ++ 28 files changed, 1614 insertions(+), 81 deletions(-) create mode 100644 crates/engine/src/cmd/actions_edit_cmds/insert_path.rs create mode 100644 crates/engine/src/cmd/actions_edit_cmds/record_again.rs create mode 100644 crates/engine/tests/it/actions_panel_menu.rs create mode 100644 crates/engine/tests/it/actions_record_again.rs create mode 100644 crates/ui-egui/src/panels/actions/menu_item.rs create mode 100644 crates/ui-egui/src/panels/actions/record_again.rs create mode 100644 crates/ui-egui/src/panels/actions/reset.rs create mode 100644 crates/ui-egui/tests/it/actions_panel_menu.rs create mode 100644 crates/ui-egui/tests/it/actions_record_again.rs diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index ea68d86b509..29543b983ec 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -88,6 +88,8 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000; #[serde(default)] pub struct ActionState { pub list: Vec, + /// Opt in to recording painting-tool strokes; ordinary commands are always recordable. + pub record_tools: bool, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -339,7 +341,7 @@ fn from_step(p: &Value, len: usize) -> Result { fn list(s: &mut Session, _p: &Value) -> Result { let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); - Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording})) + Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording, "toolRecording": s.actions.record_tools})) } fn get(s: &mut Session, p: &Value) -> Result { @@ -393,7 +395,32 @@ fn record(s: &mut Session, p: &Value) -> Result { /// Replayable journal entries not yet committed to this action while recording. pub fn pending_steps(s: &Session, action: usize) -> impl Iterator { let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from); - s.journal.iter().skip(from).filter(|(id, _)| replayable(id)) + s.journal.iter().skip(from).filter(move |(id, _)| replayable(id) && (s.actions.record_tools || !tool_stroke(id))) +} + +/// The point-based painting tools committed by the canvas; fills and gradients remain commands. +pub(crate) fn tool_stroke(id: &str) -> bool { + matches!( + id, + "paint.stroke" + | "paint.pencil" + | "paint.mixerBrush" + | "paint.colorReplacement" + | "paint.blur" + | "paint.sharpen" + | "paint.smudge" + | "paint.dodge" + | "paint.burn" + | "paint.sponge" + | "paint.cloneStamp" + | "paint.healingBrush" + | "paint.historyBrush" + | "paint.artHistoryBrush" + | "paint.patternStamp" + | "paint.backgroundEraser" + | "paint.spotHealing" + | "paint.remove" + ) } pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { @@ -760,7 +787,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null, toolRecording:bool}", enabled: always, run: list, journal: false, diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index a0cc11e64ef..d046f6918a5 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -14,6 +14,43 @@ use crate::actions_cmds::{Action, flush_recording, not_playing, pending_steps, r use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod record_again; +pub use record_again::{RecordAgainTarget, target as record_again_target}; +mod insert_path; +pub use insert_path::selected_path as selected_recording_path; + +/// Temporary empty folder until Photoshop's intended default action contents are specified. +pub const DEFAULT_SET_PLACEHOLDER: &str = "Default Actions (placeholder)"; + +/// Restore an explicit placeholder while keeping all admission checks before replacement. +fn reset(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.reset"; + not_playing(s, cmd)?; + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before resetting actions")); + } + if p.get("confirm").and_then(Value::as_bool) != Some(true) { + return Err(bad(cmd, "resetting actions requires `confirm`: true")); + } + let replace = match p.get("mode").and_then(Value::as_str) { + Some("append") => false, + Some("replace") => true, + _ => return Err(bad(cmd, "`mode` must be append or replace")), + }; + let names = if replace { Vec::new() } else { set_names(s) }; + let name = crate::atn_cmds::UniqueNames::new(names).claim(DEFAULT_SET_PLACEHOLDER); + let create = json!({"name":name}); + if let Some(gate) = s.authorize { + gate("actions.newSet", &create)?; + if replace { + gate("actions.clearAll", &json!({"confirm":true}))?; + } + } + let cleared = if replace { clear_all(s, &json!({"confirm":true}))?["cleared"].clone() } else { json!(0) }; + new_set(s, &create)?; + Ok(json!({"set":name,"placeholder":true,"actions":0,"cleared":cleared})) +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -22,6 +59,32 @@ fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } +/// Flush under the old mode so changing the toggle never retroactively records or loses a stroke. +fn allow_tool_recording(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.allowToolRecording"; + not_playing(s, cmd)?; + let enabled = match p.get("enabled") { + None | Some(Value::Null) => !s.actions.record_tools, + Some(Value::Bool(enabled)) => *enabled, + Some(_) => return Err(bad(cmd, "`enabled` must be true or false")), + }; + if enabled != s.actions.record_tools { + flush_recording(s)?; + s.actions.record_tools = enabled; + } + Ok(json!({"enabled": enabled})) +} + +/// Insert a menu event with absent settings, reusing the ordinary validated step insertion. +fn insert_menu_item(s: &mut Session, p: &Value) -> Result { + if p.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "action" | "at" | "id" | "eventKind"))) { + return Err(bad("actions.insertMenuItem", "menu items accept no recorded settings")); + } + let params = json!({"id":p.get("id"),"eventKind":p.get("eventKind").cloned().unwrap_or(json!("name"))}); + crate::atn_cmds::menu_item_command(¶ms)?; + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":crate::atn_cmds::MENU_ITEM,"params":params})) +} + fn index(p: &Value, key: &str, cmd: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -83,17 +146,17 @@ fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> /// Keep session-list Play Action targets stable when a copied action inserts a row. /// Other commands' nested JSON may own a separate action library and is not traversed. -fn shift_action_calls(actions: &mut [Action], at: usize) { +fn shift_action_calls(actions: &mut [Action], at: usize, count: usize) { for (id, params) in actions.iter_mut().flat_map(|a| &mut a.steps) { if id != "actions.play" { continue; } let Some(Value::Number(n)) = params.get("action") else { continue }; let shifted = if let Some(n) = n.as_u64() { - if n >= at as u64 { n.checked_add(1).map(Value::from) } else { None } + if n >= at as u64 { n.checked_add(count as u64).map(Value::from) } else { None } } else { // `actions.play` also accepts floating indices, rounding them before lookup. - n.as_f64().filter(|n| n.round() >= at as f64).map(|n| Value::from(n + 1.0)) + n.as_f64().filter(|n| n.round() >= at as f64).map(|n| Value::from(n + count as f64)) }; if let Some(value) = shifted { params["action"] = value; @@ -105,6 +168,9 @@ fn shift_action_calls(actions: &mut [Action], at: usize) { fn duplicate(s: &mut Session, p: &Value) -> Result { let cmd = "actions.duplicate"; not_playing(s, cmd)?; + if p.get("set").is_some() { + return duplicate_set(s, p); + } let idx = resolve(&s.actions.list, p, cmd)?; if let Some(step) = index(p, "step", cmd)? { let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); @@ -138,11 +204,53 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let at = idx + 1; let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); s.actions.list.insert(at, copied); - shift_action_calls(&mut s.actions.list, at); + shift_action_calls(&mut s.actions.list, at, 1); s.actions.touch(); Ok(result) } +/// Copy a folder below its source, using the existing first-appearance order and empty-set model. +fn duplicate_set(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + if p.get("action").is_some() || p.get("step").is_some() { + return Err(bad(cmd, "select either a set or an action/step")); + } + if s.actions.recording.is_some() { + return Err(bad(cmd, "stop recording before duplicating a set")); + } + let source = p.get("set").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`set` must be a set name"))?; + let sets = set_names(s); + if !sets.iter().any(|n| n == source) { + return Err(bad(cmd, "the selected set no longer exists")); + } + let mut set_names = crate::atn_cmds::UniqueNames::new(sets); + let name = set_names.claim(&format!("{source} copy")); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + let at = s.actions.list.iter().position(|a| a.set.as_deref() == Some(source)).map_or(s.actions.list.len(), |i| i + 1); + let added: Vec<_> = s + .actions + .list + .iter() + .filter(|a| a.set.as_deref() == Some(source)) + .cloned() + .map(|mut a| { + a.name = names.claim(&format!("{} copy", a.name)); + a.set = Some(name.clone()); + a + }) + .collect(); + let copied: Vec<_> = added.iter().map(|a| a.name.clone()).collect(); + let count = added.len(); + s.actions.list.splice(at..at, added); + if count != 0 { + shift_action_calls(&mut s.actions.list, at, count); + } + let folder = s.actions.sets.iter().position(|set| set == source).map_or(s.actions.sets.len(), |i| i + 1); + s.actions.sets.insert(folder, name.clone()); + s.actions.touch(); + Ok(json!({"set":name,"actions":copied,"index":at})) +} + /// Clear confirmed definitions and their own shortcut bindings, preserving the document. fn clear_all(s: &mut Session, p: &Value) -> Result { let cmd = "actions.clearAll"; @@ -365,6 +473,56 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.insertMenuItem", + label: "Insert Menu Item…", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"id":menu command,"at":index?,"eventKind":"name"|"code"="name"} → {action,step,id,steps}. Records an unambiguous supported menu event without settings. Image Size and mapped filter menu events are supported. Desktop playback opens the existing editor when settings are needed; no-settings items run immediately."##, + enabled: always, + run: insert_menu_item, + journal: false, + }, + CommandSpec { + id: "actions.reset", + label: "Reset Actions…", + menu: &[], + shortcut: None, + params: r##"{"mode":"append"|"replace","confirm":true} → {set,placeholder:true,actions:0,cleared}. Restores a clearly labeled empty default-set placeholder, pending clarification of Photoshop's default contents. Append keeps the current library; replace clears it after consent. This is not complete default-set parity."##, + enabled: always, + run: reset, + journal: false, + }, + CommandSpec { + id: "actions.insertPath", + label: "Insert Path", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"name":"work"|"layer"|saved path name,"at":index?} → {action,step,id,steps}. Snapshot exactly the selected path as a path.set work-path step, preserving cubic handles and path options. No fallback from a missing Work Path to a layer path. Native replay is supported; Photoshop ATN path-descriptor export remains unsupported."##, + enabled: always, + run: insert_path::run, + journal: false, + }, + CommandSpec { + id: "actions.allowToolRecording", + label: "Allow Tool Recording", + menu: &[], + shortcut: None, + params: r##"{"enabled":bool?=toggle} → {enabled:bool}. Opt in to recording painting-tool strokes (off initially). Existing pending steps are committed under the previous mode. Painting, history and the journal are unchanged. actions.list reports toolRecording."##, + enabled: always, + run: allow_tool_recording, + journal: false, + }, + CommandSpec { + id: "actions.recordAgain", + label: "Record Again", + menu: &[], + shortcut: None, + params: r##"{"action":name|index,"step":index,"params":{new settings},"revision":u64?} → {action,step,id,result}. Replays the selected command and stores its new settings only after success, preserving its enabled/dialog flags. The panel opens the command's editor at the recorded settings. Stop recording or playback first; revision optionally rejects stale library state."##, + enabled: always, + run: record_again::run, + journal: false, + }, CommandSpec { id: "actions.clearAll", label: "Clear All Actions", @@ -380,7 +538,7 @@ pub fn specs() -> Vec { label: "Duplicate", menu: &[], shortcut: None, - params: r##"{"action":name|index,"step":index?} → {action,index,step?,steps}. Duplicate the action, or one step, directly below its source. Action names gain " copy" and a unique numeric suffix if needed; settings, flags, set and color are retained. Function-key bindings stay with their original action. Stop recording before duplicating a whole action. Set duplication is not supported."##, + params: r##"{"action":name|index,"step":index?} or {"set":name} → {action?,index,step?,steps?,set?,actions?}. Duplicate the action, set or step directly below its source. Copies receive a unique " copy" name; settings, flags and colors are retained. Function-key bindings stay with their original actions. Stop recording before duplicating an action or set."##, enabled: always, run: duplicate, journal: false, diff --git a/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs new file mode 100644 index 00000000000..9be10d94adb --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/insert_path.rs @@ -0,0 +1,26 @@ +//! Capture full native geometry without substituting another path for a stale selection. +use super::{Result, Session, Value, bad, json}; +use photocraft_doc::{LayerContent, Path}; + +/// Borrow exactly the path the panel selected, without Work Path's ordinary layer fallback. +pub fn selected_path<'a>(s: &'a Session, name: &str) -> Result<&'a Path> { + let st = s.active().ok_or(crate::EngineError::NoDocument)?; + let path = match name { + "work" => st.doc.work_path.as_ref(), + "layer" => st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| match &l.content { + LayerContent::Shape(shape) => Some(&shape.path), + _ => l.vector_mask.as_ref().map(|v| &v.path), + }), + name => st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path), + }; + path.ok_or_else(|| bad("actions.insertPath", "the selected path no longer exists")) +} + +/// The native path step is also the source for a future independently verified ATN wire mapping. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("actions.insertPath", "select a path"))?; + let path = selected_path(s, name)?; + photocraft_vector::flatten::validate_path(path).map_err(|e| bad("actions.insertPath", e))?; + let path = crate::vector_cmds::path_json(path); + s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":"path.set","params":{"name":"work","path":path}})) +} diff --git a/crates/engine/src/cmd/actions_edit_cmds/record_again.rs b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs new file mode 100644 index 00000000000..b6049967bbe --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/record_again.rs @@ -0,0 +1,55 @@ +//! Record Again executes the selected command before replacing its stored settings. +use super::{Result, Session, Value, bad, index, json, not_playing, resolve}; + +const COMMAND: &str = "actions.recordAgain"; + +/// A validated selection shared by the engine and the panel's seeded editor. +pub struct RecordAgainTarget { + pub action: usize, + pub step: usize, + pub command: String, + pub params: Value, +} + +/// Resolve only executable steps, without flushing recording or editing the library. +pub fn target(s: &Session, p: &Value) -> Result { + not_playing(s, COMMAND)?; + if s.actions.recording.is_some() { + return Err(bad(COMMAND, "stop recording before recording a step again")); + } + if let Some(revision) = p.get("revision") { + let revision = revision.as_u64().ok_or_else(|| bad(COMMAND, "`revision` must be an unsigned integer"))?; + if revision != s.actions.rev { + return Err(bad(COMMAND, "the actions changed; select the step again")); + } + } + let action = resolve(&s.actions.list, p, COMMAND)?; + let step = index(p, "step", COMMAND)?.ok_or_else(|| bad(COMMAND, "select a step"))?; + let (id, params) = s.actions.list.get(action).and_then(|a| a.steps.get(step)).ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let (command, params) = + if id == crate::atn_cmds::MENU_ITEM { (crate::atn_cmds::menu_item_command(params)?.to_string(), json!({})) } else { (id.clone(), params.clone()) }; + if command.starts_with("actions.") || !crate::commands::find(&command).is_some_and(|c| c.journal) { + return Err(bad(COMMAND, "the selected step has no editable engine command")); + } + Ok(RecordAgainTarget { action, step, command, params }) +} + +/// Keep the original definition on invalid settings, denied execution or changed ownership. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let selected = target(s, p)?; + let params = p.get("params").filter(|p| p.is_object()).cloned().ok_or_else(|| bad(COMMAND, "supply the step's new settings in `params`"))?; + let original = s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)).cloned().ok_or_else(|| bad(COMMAND, "step is out of range"))?; + let revision = s.actions.rev; + // Resolve portable recorded targets for execution; keep their portable form in the step. + let resolved = crate::actions_cmds::playback_params(s, &selected.command, ¶ms)?; + let result = s.execute(&selected.command, resolved)?; + if s.actions.rev != revision || s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)) != Some(&original) { + return Err(bad(COMMAND, "the action changed during execution; its step was not overwritten")); + } + let action = s.actions.list.get_mut(selected.action).ok_or_else(|| bad(COMMAND, "the action no longer exists"))?; + let step = action.steps.get_mut(selected.step).ok_or_else(|| bad(COMMAND, "the step no longer exists"))?; + *step = (selected.command.clone(), params); + let name = action.name.clone(); + s.actions.touch(); + Ok(json!({"action": name, "step": selected.step, "id": selected.command, "result": result})) +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index a617fffbc92..3d15b013da6 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -56,7 +56,7 @@ use events::{EVENTS, event_and_descriptor, event_for}; #[cfg(test)] use fields::Mapped; use files::{load, replace, save}; -pub use menu_item::{MENU_ITEM, menu_item_command}; +pub use menu_item::{MENU_ITEM, menu_item_command, menu_item_commands, menu_item_needs_settings}; #[cfg(test)] use steps_doc::crop_from; #[cfg(test)] @@ -145,7 +145,7 @@ pub fn specs() -> Vec { label: "Menu Item", menu: &[], shortcut: None, - params: r##"{"id":"image.imageSize","eventKind":"name"|"code"="name"} (a descriptor-free Insert Menu Item step; opens Image Size during desktop playback, fails with interactive-required during headless playback)"##, + params: r##"{"id":menu command,"eventKind":"name"|"code"="name"} (a descriptor-free Image Size or unambiguous mapped filter menu event; desktop playback asks for settings when needed, headless playback only runs stateless menu items)"##, enabled: always, run: menu_item::run, journal: true, diff --git a/crates/engine/src/cmd/atn_cmds/menu_item.rs b/crates/engine/src/cmd/atn_cmds/menu_item.rs index 7eabb775df0..d982cec9971 100644 --- a/crates/engine/src/cmd/atn_cmds/menu_item.rs +++ b/crates/engine/src/cmd/atn_cmds/menu_item.rs @@ -1,12 +1,32 @@ //! Descriptor-free menu intent is distinct from a command with recorded settings. -//! Only Image Size is supported here; other absent descriptors keep their existing mapping. +//! Image Size and unambiguous mapped filter events keep absent settings distinct from empty ones. +use super::events::{EVENTS, Event, event_for}; use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json}; +/// Only unambiguous mapped filter events and the verified Image Size event have menu identities. +fn menu_event(id: &str) -> Option<&'static Event> { + if id != "image.imageSize" && !id.starts_with("filter.") { + return None; + } + EVENTS.iter().find(|e| e.ids.len() == 1 && e.ids.first().copied() == Some(id) && crate::commands::find(id).is_some()) +} + +/// Menu choices use existing event mappings rather than inventing event names from command ids. +pub fn menu_item_commands() -> Vec<&'static str> { + let mut seen = std::collections::HashSet::new(); + EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| menu_event(id).is_some() && seen.insert(*id)).collect() +} + +/// Stateless filter menu items execute immediately; settings-bearing ones require the editor. +pub fn menu_item_needs_settings(id: &str) -> bool { + crate::commands::find(id).is_none_or(|c| !c.params.trim_start().starts_with("{}")) +} + /// An Insert Menu Item step with no recorded command descriptor. pub const MENU_ITEM: &str = "actions.menuItem"; -/// Validate the supported menu intent before the shell gates and opens its actual command. +/// Validate menu identity before the shell gates and opens its actual command. pub fn menu_item_command(p: &Value) -> Result<&str> { let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?; if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind")) { @@ -15,31 +35,43 @@ pub fn menu_item_command(p: &Value) -> Result<&str> { if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) { return Err(bad(MENU_ITEM, "`eventKind` must be name or code")); } - match p.get("id").and_then(Value::as_str) { - Some("image.imageSize") => Ok("image.imageSize"), - _ => Err(bad(MENU_ITEM, "only the Image Size menu item is supported")), + let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(MENU_ITEM, "select a menu command"))?; + let event = menu_event(id).ok_or_else(|| bad(MENU_ITEM, "the command has no unambiguous supported menu event"))?; + if p.get("eventKind").and_then(Value::as_str) == Some("code") && event.code.len() != 4 { + return Err(bad(MENU_ITEM, "this menu event has only a string name")); } + Ok(id) } /// Preserve the exact code/name kind only for the supported descriptor-free event. pub(super) fn from_event(event: &AtnEvent) -> Option { + let mapped = event_for(event)?; + let id = *mapped.ids.first()?; + menu_event(id)?; let kind = match event { - AtnEvent::Code([b'I', b'm', b'g', b'S']) => "code", - AtnEvent::Name(name) if name == b"imageSize" => "name", + AtnEvent::Code(code) if mapped.code.as_bytes() == code => "code", + AtnEvent::Name(name) if mapped.name.as_bytes() == name => "name", _ => return None, }; - Some(json!({"id": "image.imageSize", "eventKind": kind})) + Some(json!({"id":id,"eventKind":kind})) } /// Write absent settings as `None`, never as an empty or current-target descriptor. pub(super) fn export(p: &Value) -> Option { - menu_item_command(p).ok()?; - let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name("imageSize") }; - Some(AtnItem { event, dictionary: b"Image Size".to_vec(), descriptor: None, ..Default::default() }) + let mapped = menu_event(menu_item_command(p).ok()?)?; + let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") { + AtnEvent::Code(mapped.code.as_bytes().try_into().ok()?) + } else { + AtnEvent::name(mapped.name) + }; + Some(AtnItem { event, dictionary: mapped.label.as_bytes().to_vec(), descriptor: None, ..Default::default() }) } -/// Headless execution cannot supply settings that were deliberately left for a dialog. -pub(super) fn run(_: &mut Session, p: &Value) -> Result { +/// Headless execution runs stateless items and refuses settings deliberately left for a dialog. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { let id = menu_item_command(p)?; + if !menu_item_needs_settings(id) { + return s.execute(id, json!({})); + } Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied"))) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 5de59280525..c6e500c4b40 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -955,6 +955,7 @@ fn group_layers_from_index_reference_stays_implicit() { } } +/// Unmapped descriptors stay opaque, while absent filter settings retain menu intent. #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a @@ -998,7 +999,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, PHOTOSHOP_STEP]); + assert_eq!(imp.actions[0].steps[4].1, json!({"id":"filter.blur.gaussianBlur","eventKind":"code"})); assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } @@ -3413,8 +3415,8 @@ fn loaded_camera_raw_filter_steps_save_back_unchanged() { } /// Review of #8714: a Camera Raw Filter step with a key the table doesn't map (an Upright -/// setting, a profile name, any other panel) or with no settings at all stays a Photoshop step -/// and is reported, rather than replaying without that processing. +/// setting, a profile name, any other panel) stays a Photoshop step and is reported rather than +/// replaying without that processing. Absent settings instead retain the menu-only event. #[test] fn camera_raw_filter_steps_with_unknown_keys_stay_photoshop_steps() { let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", D::Integer(5)).with("PrVe", D::Integer(184_549_376)); @@ -3429,13 +3431,16 @@ fn camera_raw_filter_steps_with_unknown_keys_stay_photoshop_steps() { let mut s = Session::new(); let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); let unsupported: Vec = loaded["unsupported"].as_array().unwrap().iter().map(|u| u["step"].as_u64().unwrap()).collect(); - assert_eq!(unsupported, [0, 1, 2], "{loaded}"); + assert_eq!(unsupported, [0, 1], "{loaded}"); let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.cameraRaw"]); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.cameraRaw"]); + assert_eq!(s.actions.list[0].steps[2].1, json!({"id":"filter.cameraRaw","eventKind":"name"})); // Kept, it still saves back as recorded. let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); assert_eq!(saved.actions[0].items[0].descriptor, Some(upright)); + assert_eq!(saved.actions[0].items[2].event, AtnEvent::name("Adobe Camera Raw Filter")); + assert!(saved.actions[0].items[2].descriptor.is_none()); } fn one_action(items: Vec) -> AtnFile { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ac9e38a7231..913c5f18411 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -886,7 +886,8 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; - job.recorded_by = self.actions.recording.map(|_| self.actions.take); + job.recorded_by = + self.actions.recording.filter(|_| self.actions.record_tools || !crate::actions_cmds::tool_stroke(id)).map(|_| self.actions.take); job.pre_selected = pre_selected.clone(); let jid = job.id; self.jobs.running.push(job); diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs new file mode 100644 index 00000000000..8ba9e42544c --- /dev/null +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -0,0 +1,184 @@ +//! The remaining panel commands compose with the same definitions, journal and ATN boundary. +use photocraft_engine::{EngineError, Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, jobs::Started}; +use serde_json::{Value, json}; + +/// Tiny real document with one empty action keeps recording and background jobs observable. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":40,"height":30})).unwrap(); + s.actions.list.push(Action::new("A", vec![])); + s +} + +/// A painted stripe gives both the remove tool and blur a real input before recording starts. +fn stroke(s: &mut Session, color: &str) { + s.execute("paint.stroke", json!({"points":[[8,15],[32,15]],"size":3,"color":color})).unwrap(); +} + +/// Mode changes flush under the previous mode without altering painting or ordinary recording. +#[test] +fn tool_recording_is_opt_in_and_toggles_do_not_reclassify_pending_steps() { + let mut s = session(); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + stroke(&mut s, "#ff0000"); + s.execute("select.all", json!({})).unwrap(); + let original = s.actions.clone(); + assert!(s.execute("actions.allowToolRecording", json!({"enabled":"yes"})).is_err()); + assert_eq!(s.actions, original, "invalid mode must not flush pending work"); + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + stroke(&mut s, "#00ff00"); + s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap(); + stroke(&mut s, "#0000ff"); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(), ["select.all", "paint.stroke", "select.deselect"]); + assert_eq!(s.actions.list[0].steps[1].1["color"], "#00ff00"); + assert_eq!(s.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 3, "all actual painting remains journaled"); + assert_eq!(s.execute("actions.list", json!({})).unwrap()["toolRecording"], false); +} + +/// A stroke started while off never joins a later enabled recording; ordinary jobs keep their owner. +#[test] +fn background_stroke_ownership_does_not_suppress_ordinary_filter_jobs() { + for command in ["paint.remove", "filter.blur.gaussianBlur"] { + let mut s = session(); + stroke(&mut s, "#bb3300"); + s.execute("actions.record", json!({"action":"A"})).unwrap(); + let params = if command == "paint.remove" { json!({"points":[[20,15],[21,15]],"size":3}) } else { json!({"radius":1}) }; + let Started::Job(job) = s.start(command, params).unwrap() else { panic!("expected the real background job: {command}") }; + s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap(); + s.wait_job(job).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let ids = s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::>(); + if command == "paint.remove" { + assert!(ids.is_empty()); + } else { + assert_eq!(ids, [command]); + } + } +} + +/// Interleaved membership and numeric calls expose incorrect folder placement or target rewriting. +#[test] +fn set_duplication_preserves_metadata_call_targets_and_empty_folder_order() { + let mut s = session(); + s.actions.list.clear(); + for (name, set) in [("A", "S"), ("Other", "T"), ("B", "S")] { + let mut a = Action::new(name, vec![("actions.play".into(), json!({"action":2}))]); + a.set = Some(set.into()); + a.color = "red".into(); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + } + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + let result = s.execute("actions.duplicate", json!({"set":"S"})).unwrap(); + assert_eq!(result["set"], "S copy"); + assert_eq!(actions_edit_cmds::set_names(&s), ["S", "S copy", "T"]); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["A", "A copy", "B copy", "Other", "B"]); + for a in &s.actions.list { + assert_eq!(a.steps[0].1["action"], 4); + assert_eq!(a.color, "red"); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + } + assert!(!s.prefs().shortcuts.contains_key("actions.play:A copy")); + let mut empty = Session::new(); + for name in ["E", "F"] { + empty.execute("actions.newSet", json!({"name":name})).unwrap(); + } + empty.execute("actions.duplicate", json!({"set":"E"})).unwrap(); + assert_eq!(actions_edit_cmds::set_names(&empty), ["E", "E copy", "F"]); + let original = empty.actions.clone(); + for params in [json!({"set":"Missing"}), json!({"set":"E","action":0}), json!({"set":false})] { + assert!(empty.execute("actions.duplicate", params).is_err()); + assert_eq!(empty.actions, original); + } +} + +/// Menu insertion keeps descriptor absence and kind; empty descriptors and ambiguous commands differ. +#[test] +fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() { + assert!(!atn_cmds::menu_item_needs_settings("filter.noise.despeckle")); + assert!(atn_cmds::menu_item_needs_settings("filter.blur.gaussianBlur")); + for id in ["image.imageSize", "filter.blur.gaussianBlur", "filter.noise.despeckle"] { + for kind in ["name", "code"] { + let mut s = session(); + s.execute("actions.insertMenuItem", json!({"action":"A","id":id,"eventKind":kind})).unwrap(); + let bytes = atn_cmds::export("S", &[&s.actions.list[0]], &[], false).unwrap().0; + let raw = photocraft_psd::atn::parse(&bytes).unwrap(); + assert!(raw.actions[0].items[0].descriptor.is_none()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0], (atn_cmds::MENU_ITEM.into(), json!({"id":id,"eventKind":kind}))); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}"); + } + } + let mut s = session(); + let original = s.actions.clone(); + for params in + [json!({"action":"A","id":"layer.setProps"}), json!({"action":"A","id":"file.open"}), json!({"action":"A","id":"image.imageSize","params":{"width":4}})] + { + assert!(s.execute("actions.insertMenuItem", params).is_err()); + assert_eq!(s.actions, original); + } + let mut raw = photocraft_psd::atn::AtnFile::default(); + raw.actions.push(photocraft_psd::atn::AtnAction { + items: vec![photocraft_psd::atn::AtnItem { + event: photocraft_psd::atn::AtnEvent::name("gaussianBlur"), + descriptor: Some(photocraft_psd::descriptor::Descriptor::new("null")), + ..Default::default() + }], + ..Default::default() + }); + let loaded = atn_cmds::import(&photocraft_psd::atn::write(&raw).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, "filter.blur.gaussianBlur"); +} + +/// A full curve is snapshotted independently of later source edits and replays as the work path. +#[test] +fn insert_path_captures_cubic_geometry_without_editing_the_document() { + let mut s = session(); + let path = json!({"fillRule":"evenodd","inverted":true,"subpaths":[{"closed":false,"op":"combine","knots":[{"anchor":[1.5,2.25],"in":[-1.0,3.0],"out":[5.0,8.0],"smooth":true},{"anchor":[20.0,18.0],"in":[12.0,5.0],"out":[21.0,19.0]}]}]}); + s.execute("path.set", json!({"name":"Curve","path":path})).unwrap(); + let expected = s.execute("path.info", json!({"name":"Curve"})).unwrap()["path"].clone(); + let revision = s.active().unwrap().revision; + assert!(s.execute("actions.insertPath", json!({"action":"A","name":"work"})).is_err()); + assert!(s.actions.list[0].steps.is_empty()); + s.execute("actions.insertPath", json!({"action":"A","name":"Curve"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.actions.list[0].steps[0].1["path"], expected); + s.execute("path.set", json!({"name":"Curve","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.execute("path.info", json!({"name":"work"})).unwrap()["path"], expected); + assert!(atn_cmds::export("S", &[&s.actions.list[0]], &[], false).is_err(), "native capture must not pretend an unverified ATN descriptor is supported"); +} + +/// Reject a nested creation before reset can clear the existing library. +fn deny_new_set(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "actions.newSet" { Err(EngineError::Other("new set denied".into())) } else { Ok(()) } +} + +/// The placeholder is explicit, both modes work, and all refusals precede destructive mutation. +#[test] +fn reset_placeholder_appends_or_replaces_only_after_valid_consent() { + let mut s = session(); + let original = s.actions.clone(); + for p in [json!({}), json!({"mode":"replace"}), json!({"mode":"wrong","confirm":true}), json!({"mode":"append","confirm":false})] { + assert!(s.execute("actions.reset", p).is_err()); + assert_eq!(s.actions, original); + } + s.authorize = Some(deny_new_set); + assert!(s.execute("actions.reset", json!({"mode":"replace","confirm":true})).is_err()); + assert_eq!(s.actions, original); + s.authorize = None; + let result = s.execute("actions.reset", json!({"mode":"append","confirm":true})).unwrap(); + assert_eq!(result["placeholder"], true); + assert_eq!(s.actions.list, original.list); + s.execute("actions.reset", json!({"mode":"replace","confirm":true})).unwrap(); + assert!(s.actions.list.is_empty()); + assert_eq!(s.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); +} diff --git a/crates/engine/tests/it/actions_record_again.rs b/crates/engine/tests/it/actions_record_again.rs new file mode 100644 index 00000000000..9e3b02367b2 --- /dev/null +++ b/crates/engine/tests/it/actions_record_again.rs @@ -0,0 +1,77 @@ +//! Record Again edits one definition only after its concrete command succeeds. +use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; +use serde_json::{Value, json}; + +/// A disabled, dialog-enabled resize followed by another step exposes flag and ordering loss. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let mut a = Action::new("A", vec![("image.imageSize".into(), json!({"width": 4, "height": 3, "resample": "nearest"})), ("select.all".into(), json!({}))]); + a.disabled.insert(0); + a.dialogs.insert(0); + s.actions.list.push(a); + s +} + +/// Only the selected step runs, retains its flags, and exports its new settings to ATN. +#[test] +fn successful_rerecording_updates_one_step_and_roundtrips_new_settings() { + for menu in [false, true] { + let mut s = session(); + if menu { + s.actions.list[0].steps[0] = (atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"})); + } + let revision = s.actions.rev; + s.execute("actions.recordAgain", json!({"action": "A", "step": 0, "revision": revision, "params": {"width": 6, "height": 5, "resample": "nearest"}})) + .unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(s.active().unwrap().doc.selection.is_none(), "the following step did not play"); + let a = &s.actions.list[0]; + assert_eq!(a.steps.len(), 2); + assert_eq!(a.steps[0].0, "image.imageSize"); + assert_eq!(a.steps[0].1["width"], 6); + assert!(a.disabled.contains(&0)); + assert!(a.dialogs.contains(&0)); + assert_eq!(s.actions.rev, revision + 1); + assert_eq!(s.journal.last().unwrap().0, "image.imageSize"); + let bytes = atn_cmds::export("Set", &[a], &[], false).unwrap().0; + let loaded = atn_cmds::import(&bytes).unwrap(); + assert_eq!(loaded.actions[0].steps[0].1["width"].as_f64(), Some(6.0)); + assert!(loaded.actions[0].disabled.contains(&0)); + assert!(loaded.actions[0].dialogs.contains(&0)); + } +} + +/// Permit the wrapper while denying its concrete edit, proving nested authorization remains active. +fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) } +} + +/// Invalid settings, stale selections, denied execution and recording preserve the original step. +#[test] +fn failed_or_stale_rerecording_never_replaces_the_original_step() { + let mut s = session(); + let original = s.actions.list.clone(); + let revision = s.active().unwrap().revision; + for request in [ + json!({"action":"A","step":0}), + json!({"action":"A","step":0,"params":[]}), + json!({"action":"A","step":0,"params":{"percent":-1}}), + json!({"action":"A","step":99,"params":{}}), + json!({"action":"Missing","step":0,"params":{}}), + json!({"action":"A","step":0,"revision":1,"params":{}}), + ] { + assert!(s.execute("actions.recordAgain", request.clone()).is_err(), "{request}"); + assert_eq!(s.actions.list, original); + assert_eq!(s.actions.rev, 0); + assert_eq!(s.active().unwrap().revision, revision); + } + s.authorize = Some(deny_resize); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{"width":6,"height":5}})).is_err()); + assert_eq!(s.actions.list, original); + s.authorize = None; + s.execute("actions.record", json!({"action":"A"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{}})).is_err()); + assert_eq!(s.actions.list, original, "recording was not flushed by a refused edit"); +} diff --git a/crates/engine/tests/it/atn_menu_item.rs b/crates/engine/tests/it/atn_menu_item.rs index ee914cf41e4..6b6193b4c6f 100644 --- a/crates/engine/tests/it/atn_menu_item.rs +++ b/crates/engine/tests/it/atn_menu_item.rs @@ -13,6 +13,7 @@ fn file(item: AtnItem) -> Vec { actions: vec![AtnAction { name: UnicodeString::new_nul("Resize"), items: vec![item], ..Default::default() }], expanded: true, warnings: vec![], + ..Default::default() }) .unwrap() } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f4292718450..1f5ad2f1425 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,8 @@ mod actions_clear_all; mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; mod actions_replace; mod affinity; mod artboard_export_names; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index ac62672c132..1a3058187d4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -194,6 +194,10 @@ impl PhotocraftApp { held_opens: Default::default(), held_preset_import: None, held_actions_replace: None, + held_record_again: None, + held_actions_reset: None, + held_insert_menu: None, + held_actions_clear: None, allow_close: false, stylus: Default::default(), control_click: Default::default(), diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index a7d3f8dc949..f3ad6052ffa 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -58,6 +58,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } params = crate::select_mask_view::request_params(&d.fields, params); } + crate::actions::authorize_preview(app, d.id, &cmd, ¶ms).ok()?; let st = app.session.documents().get(idx)?; let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); let request = crate::filter_dialog::FilterPreviewKey { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f0da8f88de8..e7351ef4765 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -278,6 +278,9 @@ pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool { /// The body of a command dialog: the first handler whose marker the fields carry. The prompts and /// small editors come first; the rest are in `command_body_more`. fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id, interactive: bool) -> Option { + if crate::actions::owned_dialog_body(app, ui, d.id, fields) { + return None; + } let mut outcome: Option = None; match d.kind { _ if crate::open_prompt::owns_damage(fields) => crate::open_prompt::damage_body(ui, fields), @@ -296,8 +299,6 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::replace_prompt::owns(fields) => crate::replace_prompt::body(ui, fields), _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), - _ if crate::actions::clear_all::owns(fields) => crate::actions::clear_all::body(ui), - _ if crate::actions::replace::owns(fields) => crate::actions::replace::body(app, ui, d.id), _ if crate::actions::owns_new_set(fields) => crate::actions::new_set_body(ui, fields), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), _ if crate::mask_trash_prompt::owns(fields) => crate::mask_trash_prompt::body(ui, fields), @@ -850,7 +851,10 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::actions::owns_insert_stop, crate::actions::clear_all::owns, crate::actions::replace::owns, + crate::actions::reset::owns, + crate::actions::menu_item::owns, crate::actions::owns_new_set, + crate::actions::record_again::owns, crate::new_layer_ui::owns, crate::new_group_ui::owns, crate::mask_trash_prompt::owns, @@ -909,7 +913,7 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Result Result Result<(), String> { + if let Some(dialog) = app.ui.dialogs.iter().find(|d| d.id == id) { + crate::actions::validate_editor(app, id, &dialog.fields)?; + } if crate::layer_style::color_picker::has_child(app, id) { return Err("confirm or cancel the Layer Style color picker first".into()); } @@ -992,6 +999,9 @@ fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { /// Confirm a command dialog through the first handler whose marker its fields carry; the prompts /// and small editors are here, the rest in `confirm_command_more`. fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result { + if let Some(result) = crate::actions::confirm_owned_dialog(app, d.id, &d.fields) { + return result; + } match d.kind { _ if crate::open_prompt::owns_damage(&d.fields) => crate::open_prompt::answer_damage(app, true), _ if crate::guide_layout_ui::owns(&d.fields) => crate::guide_layout_ui::confirm(app, &d.fields), @@ -1009,8 +1019,6 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result _ if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app), _ if crate::preset_group_prompt::owns(&d.fields) => crate::preset_group_prompt::confirm(app, &d.fields), _ if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), - _ if crate::actions::clear_all::owns(&d.fields) => crate::actions::clear_all::confirm(app, &d.fields), - _ if crate::actions::replace::owns(&d.fields) => crate::actions::replace::confirm(app, d.id, &d.fields), _ if crate::actions::owns_new_set(&d.fields) => crate::actions::new_set_confirm(app, &d.fields), _ if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), _ if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), @@ -1062,10 +1070,22 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { /// Cancel a dialog and its dependent Layer Style picker, without applying edits. pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { let closed = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; - crate::actions::stopped(app, &closed.fields); + crate::actions::stopped(app, id, &closed.fields); if crate::actions::replace::owns(&closed.fields) { crate::actions::replace::cancel(app, id); } + if crate::actions::reset::owns(&closed.fields) { + crate::actions::reset::cancel(app, id); + } + if crate::actions::menu_item::owns(&closed.fields) { + crate::actions::menu_item::cancel(app, id); + } + if crate::actions::clear_all::owns(&closed.fields) { + crate::actions::clear_all::cancel(app, id); + } + if crate::actions::record_again::owns(&closed.fields) { + crate::actions::record_again::cancel(app, id); + } if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } diff --git a/crates/ui-egui/src/dialogs/adjust_preview.rs b/crates/ui-egui/src/dialogs/adjust_preview.rs index 5d060f2c6a6..0c9f62ea0bd 100644 --- a/crates/ui-egui/src/dialogs/adjust_preview.rs +++ b/crates/ui-egui/src/dialogs/adjust_preview.rs @@ -191,7 +191,9 @@ fn dialog(app: &PhotocraftApp) -> Option<(String, Value, bool)> { } let kind = d.fields.get("__adjust")?.as_str()?.to_string(); let preview = d.fields.get("__preview").and_then(Value::as_bool).unwrap_or(true); - Some((kind, crate::filter_dialog::params_of(&d.fields), preview)) + let params = crate::filter_dialog::params_of(&d.fields); + crate::actions::authorize_preview(app, d.id, &format!("image.adjustments.{kind}"), ¶ms).ok()?; + Some((kind, params, preview)) } /// FNV-1a over `parts`, never 0. diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 0f87c26faff..e810d0086d5 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -1095,7 +1095,7 @@ pub(crate) fn needs_displace_map(command: &str, params: &Value) -> bool { /// browser the picker gives contents, not a path the filter can read: the map has to be opened /// as a document and picked as Map Document there. It displaces the document that asked, even /// when another one was activated while the picker was open. -pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: Map) -> Result { +pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, dialog: u64, command: String, fields: Map) -> Result { use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; let doc = app.active_doc_id()?; let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); @@ -1112,7 +1112,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: fields.insert("mapPath".into(), json!(path)); app.refocus(doc)?; let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?; - let result = app.run(&cmd, params); + let result = crate::actions::run_dialog_command(app, dialog, &fields, &cmd, params); if result.is_ok() { remember(app, &cmd, &fields); let _ = crate::actions::resume(app, &fields); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f675985316..61252d9dc13 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -691,6 +691,14 @@ pub struct PhotocraftApp { pub(crate) held_preset_import: Option, /// The exact Actions input awaiting its own replacement confirmation. pub(crate) held_actions_replace: Option, + /// The step and authority owned by its Record Again editor. + pub(crate) held_record_again: Option, + /// Consent and authority owned by the Reset Actions append/replace prompt. + pub(crate) held_actions_reset: Option, + /// Target and authority owned by the Insert Menu Item chooser. + pub(crate) held_insert_menu: Option, + /// The Clear All warning retains its caller until its own confirmation or Cancel. + pub(crate) held_actions_clear: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 05747cbb0f8..22883f8ba7b 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -15,7 +15,10 @@ use crate::icons; use crate::theme::Tokens; pub(crate) mod clear_all; +pub(crate) mod menu_item; +pub(crate) mod record_again; pub(crate) mod replace; +pub(crate) mod reset; /// Which row is selected and which rows are expanded. The action list itself is /// [`photocraft_engine::Session::actions`]. @@ -239,13 +242,16 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); break; } - if menu && app.automation_input { + let menu_settings = menu && photocraft_engine::atn_cmds::menu_item_needs_settings(id); + if menu_settings && app.automation_input { failed = Some(json!({"step": i, "id": id, "error": format!("interactive-required: `{id}` needs its menu item dialog")})); break; } // The dialog toggle: show the step's dialog with its recorded values and continue // from the next step when it is confirmed (`resume`). Agents play headlessly. - if menu || (action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id))) { + if menu_settings + || (!menu && action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id))) + { let label = label_of(id); let dialog = if menu { // Use the same populated Image Size dialog as the native menu, after the @@ -254,15 +260,22 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) failed = Some(json!({"step": i, "id": id, "error": e.to_string()})); break; } - let Some(dialog) = crate::sizing::open(app, id) else { - failed = Some(json!({"step": i, "id": id, "error": "no document"})); - break; - }; - dialog + if crate::sizing::is_sizing(id) { + let Some(dialog) = crate::sizing::open(app, id) else { + failed = Some(json!({"step":i,"id":id,"error":"no document"})); + break; + }; + dialog + } else { + crate::dialogs::open_command_dialog(app, id, &label) + } } else { crate::dialogs::open_command_dialog(app, id, &label) }; let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { + paused.editor = Some((dialog, id.to_string())); + } if let Some(d) = app.ui.dialog_mut(dialog) { if let Some(o) = p.as_object() { // `__` keys are the dialog's own markers (`params_of` drops them), not step values. @@ -709,7 +722,18 @@ fn suspended(app: &PhotocraftApp) -> bool { /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.clearAll" | "actions.replace") && suspended(app) { + if matches!( + id, + "actions.record" + | "actions.delete" + | "actions.move" + | "actions.duplicate" + | "actions.clearAll" + | "actions.replace" + | "actions.recordAgain" + | "actions.reset" + ) && suspended(app) + { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) @@ -724,6 +748,22 @@ pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map photocraft_engine::Result<()>; +/// Deferred Actions dialogs retain their caller's gate and also honor current permissions. +fn authorize_owned(app: &PhotocraftApp, id: &str, params: &Value, gate: Option, automated: bool) -> Result<(), String> { + if let Some(gate) = gate { + gate(id, params).map_err(|e| e.to_string())?; + } + if let Some(current) = app.session.authorize { + current(id, params).map_err(|e| e.to_string())?; + } + if (automated || app.automation_input || app.session.authorize.is_some()) + && let Some(check) = app.services.automation_command.as_ref() + { + check(id, params)?; + } + Ok(()) +} + /// A playback paused at a step dialog: the action and step it continues from, and the gate it /// was playing under (`None` for one a person started). #[derive(Clone, Debug)] @@ -731,6 +771,8 @@ pub(crate) struct PausedPlayback { action: String, from: usize, gate: Option, + /// The real editor and fixed command; editable fields hold only its read-only resume token. + editor: Option<(u64, String)>, /// The recording the paused call suspended: `{index, into, action, from}` (`into` is the /// name of the action being recorded). A step dialog only closes through /// `dialogs::confirm` (OK, `↩`, automation) or `dialogs::cancel` (Cancel, Esc, the close @@ -757,7 +799,7 @@ pub(crate) struct PausedPlaybacks { impl PausedPlaybacks { fn pause(&mut self, action: String, from: usize, gate: Option) -> u64 { self.last = self.last.wrapping_add(1); - self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0 }); + self.paused.insert(self.last, PausedPlayback { action, from, gate, editor: None, record: None, then: Vec::new(), ran: 0 }); self.last } @@ -766,14 +808,96 @@ impl PausedPlaybacks { } } -/// Judge the dialog's final settings under the playback gate as well as the current gate. -pub(crate) fn authorize_dialog(app: &PhotocraftApp, fields: &serde_json::Map, id: &str, params: &Value) -> Result<(), String> { - if let Some(gate) = fields.get(RESUME).and_then(Value::as_u64).and_then(|token| app.paused_playbacks.paused.get(&token)).and_then(|paused| paused.gate) { - gate(id, params).map_err(|e| e.to_string())?; +/// Find the private owner by actual editor identity rather than a mutable command field. +fn playback_editor(app: &PhotocraftApp, dialog: u64) -> Option<&PausedPlayback> { + app.paused_playbacks.paused.values().find(|p| p.editor.as_ref().is_some_and(|(id, _)| *id == dialog)) +} + +/// The read-only resume token may only settle its own real command editor. +pub(crate) fn validate_editor(app: &PhotocraftApp, dialog: u64, fields: &serde_json::Map) -> Result<(), String> { + let Some(token) = fields.get(RESUME) else { return Ok(()) }; + let owner = token + .as_u64() + .and_then(|t| app.paused_playbacks.paused.get(&t)) + .and_then(|p| p.editor.as_ref()) + .ok_or("this editor no longer belongs to a paused action")?; + if owner.0 != dialog + || fields.get("__command").and_then(Value::as_str) != Some(owner.1.as_str()) + || fields.get("__adjust").and_then(Value::as_str).is_some_and(|kind| format!("image.adjustments.{kind}") != owner.1) + { + return Err("the paused action editor or command changed".into()); } Ok(()) } +/// Both preview routes honor Record Again and paused action/menu editors before evaluation. +pub(crate) fn authorize_preview(app: &PhotocraftApp, dialog: u64, command: &str, params: &Value) -> Result<(), String> { + record_again::authorize_preview(app, dialog, command, params)?; + if let Some(owner) = playback_editor(app, dialog) { + if owner.editor.as_ref().is_none_or(|(_, id)| id != command) { + return Err("the paused action preview command changed".into()); + } + authorize_owned(app, command, params, owner.gate, owner.gate.is_some())?; + } + Ok(()) +} + +/// Final synchronous and picker callbacks execute under the captured caller's nested-command gate. +pub(crate) fn run_dialog_command( + app: &mut PhotocraftApp, + dialog: u64, + fields: &serde_json::Map, + command: &str, + params: Value, +) -> Result { + validate_editor(app, dialog, fields)?; + authorize_preview(app, dialog, command, ¶ms)?; + let gate = playback_editor(app, dialog).and_then(|p| p.gate); + let saved = app.session.authorize; + if let (Some(old), Some(current)) = (gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err("command permissions changed; request playback again".into()); + } + app.session.authorize = gate.or(saved); + let result = app.run(command, params); + app.session.authorize = saved; + result +} + +/// The owned panel editors share a small dispatch seam instead of lengthening the global router. +pub(crate) fn owned_dialog_body(app: &PhotocraftApp, ui: &mut egui::Ui, dialog: u64, fields: &mut serde_json::Map) -> bool { + if clear_all::owns(fields) { + clear_all::body(ui); + } else if replace::owns(fields) { + replace::body(app, ui, dialog); + } else if reset::owns(fields) { + reset::body(ui, fields); + } else if menu_item::owns(fields) { + menu_item::body(ui, fields); + } else { + return false; + } + true +} + +/// Route only the finite Actions panel editors; unrelated command dialogs keep their handlers. +pub(crate) fn confirm_owned_dialog(app: &mut PhotocraftApp, dialog: u64, fields: &serde_json::Map) -> Option> { + Some(if record_again::owns(fields) { + record_again::confirm(app, dialog, fields) + } else if clear_all::owns(fields) { + clear_all::confirm(app, dialog, fields) + } else if replace::owns(fields) { + replace::confirm(app, dialog, fields) + } else if reset::owns(fields) { + reset::confirm(app, dialog, fields) + } else if menu_item::owns(fields) { + menu_item::confirm(app, dialog, fields) + } else { + return None; + }) +} + /// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this). /// /// The remaining steps run under the gate the playback paused with, however the dialog was @@ -849,9 +973,11 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) } /// A step dialog cancelled: the action stops there, as in Photoshop. -pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) { - if fields.contains_key(RESUME) { - abandoned(app, fields); +pub fn stopped(app: &mut PhotocraftApp, dialog: u64, _fields: &serde_json::Map) { + let token = app.paused_playbacks.paused.iter().find_map(|(token, p)| p.editor.as_ref().is_some_and(|(id, _)| *id == dialog).then_some(*token)); + if let Some(token) = token { + let record = app.paused_playbacks.paused.remove(&token).and_then(|p| p.record); + restore_recording(app, record.as_ref()); app.ui.status = tl!("Action playback stopped").to_string(); app.ui.status_error = false; } @@ -870,9 +996,12 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.button(tl!("New Set…")).clicked() { act = Some("newSet"); } - if ui.add_enabled(can_delete, egui::Button::new(tl!("Duplicate"))).clicked() { + if ui.add_enabled(can_delete || (!recording && selected_set(app).is_some()), egui::Button::new(tl!("Duplicate"))).clicked() { act = Some("duplicate"); } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { + act = Some("delete"); + } if ui.add_enabled(any, egui::Button::new(tl!("Play"))).clicked() { act = Some("play"); } @@ -883,17 +1012,32 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if ui.button(tl!("Start Recording")).clicked() { act = Some("record"); } - if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete"))).clicked() { - act = Some("delete"); + if ui.add_enabled(selected && app.ui.actions.selected_step.is_some() && !recording, egui::Button::new(tl!("Record Again"))).clicked() { + act = Some("recordAgain"); } ui.separator(); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Menu Item…"))).clicked() { + act = Some("insertMenu"); + } if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { act = Some("stop"); } + let path_selected = + app.ui.selected_path.as_deref().is_some_and(|name| photocraft_engine::actions_edit_cmds::selected_recording_path(&app.session, name).is_ok()); + if ui.add_enabled(selected && path_selected, egui::Button::new(tl!("Insert Path"))).clicked() { + act = Some("insertPath"); + } + let mut tools = app.session.actions.record_tools; + if crate::widgets::checkbox(ui, &mut tools, tl!("Allow Tool Recording")).changed() { + act = Some("toolRecording"); + } ui.separator(); if ui.add_enabled((any || !app.session.actions.sets.is_empty()) && !recording, egui::Button::new(tl!("Clear All Actions"))).clicked() { act = Some("clearAll"); } + if ui.add_enabled(!recording, egui::Button::new(tl!("Reset Actions…"))).clicked() { + act = Some("reset"); + } if ui.button(tl!("Load Actions…")).clicked() { act = Some("load"); } @@ -931,7 +1075,15 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Ok(Value::Null) } "duplicate" => duplicate_selection(app), + "recordAgain" => record_again::open(app), + "toolRecording" => app.run("actions.allowToolRecording", json!({"enabled": !app.session.actions.record_tools})), + "insertPath" => app.run( + "actions.insertPath", + json!({"action":app.ui.actions.selected,"at":app.ui.actions.selected_step.map(|i|i.saturating_add(1)),"name":app.ui.selected_path}), + ), + "insertMenu" => menu_item::open(app), "clearAll" => clear_all::open(app), + "reset" => reset::open(app), "load" => load_dialog(app), "replace" => replace::open(app), "newSet" => new_set_dialog(app), @@ -997,6 +1149,23 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { /// Keep the selection and disclosure rows on the new copy after the engine edits the list. fn duplicate_selection(app: &mut PhotocraftApp) -> Result { + if let Some(set) = selected_set(app).map(str::to_string) { + let old_len = app.session.actions.list.len(); + let result = app.run("actions.duplicate", json!({"set":set}))?; + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + app.ui.actions.selected_set = result.get("set").and_then(Value::as_str).map(str::to_string); + let index = result.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).unwrap_or(old_len); + let count = app.session.actions.list.len().saturating_sub(old_len); + app.ui.actions.expanded.resize(old_len, false); + if index <= app.ui.actions.expanded.len() { + app.ui.actions.expanded.splice(index..index, std::iter::repeat_n(false, count)); + } + if let Some(set) = &app.ui.actions.selected_set { + app.ui.actions.collapsed_sets.retain(|s| s != set); + } + return Ok(result); + } let action = app.ui.actions.selected.ok_or_else(|| tl!("Select an action first").to_string())?; let step = app.ui.actions.selected_step; let count = app.session.actions.list.len(); diff --git a/crates/ui-egui/src/panels/actions/clear_all.rs b/crates/ui-egui/src/panels/actions/clear_all.rs index 87d420e2bc4..e629a7b1b17 100644 --- a/crates/ui-egui/src/panels/actions/clear_all.rs +++ b/crates/ui-egui/src/panels/actions/clear_all.rs @@ -8,6 +8,14 @@ const MARK: &str = "__clearActions"; /// The library revision the warning applies to; control clients cannot replace it. pub(crate) const REVISION: &str = "__clearActionsRevision"; +/// Only the real warning owns the reviewed library and its caller's authority. +pub(crate) struct Held { + dialog: u64, + revision: u64, + gate: Option, + automated: bool, +} + /// Whether this is the fixed Clear All confirmation, rather than an arbitrary command form. pub(crate) fn owns(fields: &Map) -> bool { fields.contains_key(MARK) @@ -15,18 +23,20 @@ pub(crate) fn owns(fields: &Map) -> bool { /// Open without changing definitions; the actual command is authorized before prompting. pub(super) fn open(app: &mut PhotocraftApp) -> Result { + super::suspended_guard(app, COMMAND)?; + if app.held_actions_clear.is_some() { + return Err(tl!("Confirm or cancel the pending Clear All warning first.").into()); + } + if app.session.actions.recording.is_some() || app.session.actions.playing > 0 { + return Err(tl!("Stop recording or playback before clearing actions.").into()); + } let params = json!({"confirm": true}); let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); - if let Some(gate) = gate { - gate(COMMAND, ¶ms).map_err(|e| e.to_string())?; - } - if app.automation_input - && let Some(authorize) = app.services.automation_command.as_ref() - { - authorize(COMMAND, ¶ms)?; - } + let automated = app.automation_input || gate.is_some(); + super::authorize_owned(app, COMMAND, ¶ms, gate, automated)?; let fields = json!({MARK: true, REVISION: app.session.actions.rev, "__command": COMMAND, "__label": "Clear All Actions"}); let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_actions_clear = Some(Held { dialog, revision: app.session.actions.rev, gate, automated }); Ok(json!({"dialog": dialog})) } @@ -36,14 +46,20 @@ pub(crate) fn body(ui: &mut egui::Ui) { } /// Reject a changed library before dispatch, then reset the panel selection after success. -pub(crate) fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_actions_clear.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No Clear All warning belongs to this dialog.").into()); + } + let held = app.held_actions_clear.take().ok_or(tl!("No Clear All warning is waiting."))?; if fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { return Err(tl!("Invalid Clear All Actions command").into()); } - if fields.get(REVISION).and_then(Value::as_u64) != Some(app.session.actions.rev) { + if held.revision != app.session.actions.rev || fields.get(REVISION).and_then(Value::as_u64) != Some(held.revision) { return Err(tl!("The actions changed. Request Clear All Actions again.").into()); } - let result = app.run(COMMAND, json!({"confirm": true}))?; + let params = json!({"confirm":true}); + super::authorize_owned(app, COMMAND, ¶ms, held.gate, held.automated)?; + let result = app.run(COMMAND, params)?; app.ui.actions.selected = None; app.ui.actions.selected_set = None; app.ui.actions.selected_step = None; @@ -52,3 +68,10 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, fields: &Map) -> R app.ui.actions.reveal_recording = None; Ok(result) } + +/// An unrelated Cancel cannot discard this warning's retained authority. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_actions_clear.as_ref().is_some_and(|h| h.dialog == dialog) { + app.held_actions_clear = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/menu_item.rs b/crates/ui-egui/src/panels/actions/menu_item.rs new file mode 100644 index 00000000000..666f6e101fd --- /dev/null +++ b/crates/ui-egui/src/panels/actions/menu_item.rs @@ -0,0 +1,82 @@ +//! Choose an existing mapped menu event without borrowing current command settings. +use crate::{PhotocraftApp, state::DialogKind}; +use serde_json::{Map, Value, json}; +const COMMAND: &str = "actions.insertMenuItem"; +const MARK: &str = "__insertMenuItem"; + +/// Keep a chooser tied to its original action, library and caller. +pub(crate) struct Held { + dialog: u64, + revision: u64, + target: Value, + gate: Option, + automated: bool, +} + +/// Register the chooser with the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Open without recording a step or sampling the selected command's current settings. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_insert_menu.is_some() { + return Err(tl!("Confirm or cancel the pending menu item first.").into()); + } + let action = app.ui.actions.selected.filter(|i| *i < app.session.actions.list.len()).ok_or(tl!("Select an action first."))?; + let target = json!({"action":action,"at":app.ui.actions.selected_step.map(|i|i.saturating_add(1))}); + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let automated = app.automation_input || gate.is_some(); + let mut params = target.clone(); + params["id"] = json!("image.imageSize"); + super::authorize_owned(app, COMMAND, ¶ms, gate, automated)?; + let fields = json!({MARK:true,"__command":COMMAND,"__label":"Insert Menu Item","id":"image.imageSize"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_insert_menu = Some(Held { dialog, revision: app.session.actions.rev, target, gate, automated }); + Ok(json!({"dialog":dialog})) +} + +/// List only menu identities the existing ATN event table can encode unambiguously. +pub(crate) fn body(ui: &mut egui::Ui, fields: &mut Map) { + ui.label(tl!("Choose a menu command. Playback asks for its settings when needed.")); + let mut id = fields.get("id").and_then(Value::as_str).unwrap_or("image.imageSize").to_string(); + craft_ui::ComboBox::from_id_salt("actions-insert-menu").selected_text(super::label_of(&id)).show_ui(ui, |ui| { + for command in photocraft_engine::atn_cmds::menu_item_commands() { + ui.selectable_value(&mut id, command.to_string(), super::label_of(command)); + } + }); + fields.insert("id".into(), json!(id)); +} + +/// Consent inserts only into the captured action and uses both retained and current authority. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_insert_menu.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No menu item belongs to this chooser.").into()); + } + let held = app.held_insert_menu.take().ok_or(tl!("No menu item is waiting."))?; + if held.revision != app.session.actions.rev || fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { + return Err(tl!("The action or insertion command changed. Request Insert Menu Item again.").into()); + } + let mut params = held.target; + params["id"] = fields.get("id").cloned().unwrap_or(Value::Null); + super::authorize_owned(app, COMMAND, ¶ms, held.gate, held.automated)?; + let saved = app.session.authorize; + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Insert Menu Item again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, params); + app.session.authorize = saved; + let result = result?; + app.ui.actions.selected_step = result.get("step").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + Ok(result) +} + +/// Only its own Cancel may release the chooser's target. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_insert_menu.as_ref().is_some_and(|h| h.dialog == dialog) { + app.held_insert_menu = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/record_again.rs b/crates/ui-egui/src/panels/actions/record_again.rs new file mode 100644 index 00000000000..4cdedeeefb1 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/record_again.rs @@ -0,0 +1,132 @@ +//! One editor owns one original step; previews and confirmation retain its authority. +use photocraft_engine::{actions_edit_cmds, doc::DocId}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; + +const COMMAND: &str = "actions.recordAgain"; +const MARK: &str = "__recordAgain"; + +/// Selection, document and authority are private rather than editable dialog parameters. +pub(crate) struct Held { + dialog: u64, + selection: Value, + command: String, + document: Option<(DocId, u64)>, + gate: Option, + automated: bool, +} + +/// The marker uses the existing protected route mechanism. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Apply captured and current permissions to the actual operation and final settings. +fn authorize(app: &PhotocraftApp, held: &Held, id: &str, params: &Value) -> Result<(), String> { + super::authorize_owned(app, id, params, held.gate, held.automated) +} + +/// Refuse stale selection or a different document before preview or confirmation can edit it. +fn unchanged(app: &PhotocraftApp, held: &Held) -> Result<(), String> { + super::suspended_guard(app, COMMAND)?; + if held.selection.get("revision").and_then(Value::as_u64) != Some(app.session.actions.rev) + || app.session.actions.recording.is_some() + || app.session.actions.playing > 0 + { + return Err(tl!("The actions changed. Request Record Again again.").into()); + } + if app.session.active().map(|s| (s.doc.id, s.revision)) != held.document { + return Err(tl!("The document changed. Request Record Again again.").into()); + } + Ok(()) +} + +/// Open the existing command editor and seed its controls from this step's recorded values. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + if app.held_record_again.is_some() { + return Err(tl!("Confirm or cancel the pending Record Again editor first.").into()); + } + let action = app.ui.actions.selected.ok_or(tl!("Select an action step first."))?; + let step = app.ui.actions.selected_step.ok_or(tl!("Select an action step first."))?; + let selection = json!({"action": action, "step": step, "revision": app.session.actions.rev}); + let target = actions_edit_cmds::record_again_target(&app.session, &selection).map_err(|e| e.to_string())?; + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let mut held = Held { + dialog: 0, + selection, + command: target.command, + document: app.session.active().map(|s| (s.doc.id, s.revision)), + gate, + automated: app.automation_input || gate.is_some(), + }; + unchanged(app, &held)?; + authorize(app, &held, COMMAND, &held.selection)?; + authorize(app, &held, &held.command, &target.params)?; + let resolved = photocraft_engine::actions_cmds::playback_params(&app.session, &held.command, &target.params).map_err(|e| e.to_string())?; + app.session.precheck(&held.command, &resolved).map_err(|e| e.to_string())?; + let dialog = if crate::sizing::is_sizing(&held.command) { + crate::sizing::open(app, &held.command).ok_or(tl!("No document."))? + } else if crate::filter_dialog::has_dialog(&held.command) || crate::adjust_dialog::has_dialog(&held.command) { + crate::dialogs::open_command_dialog(app, &held.command, &super::label_of(&held.command)) + } else { + // Commands without a bespoke editor still use the existing command-schema form. + crate::filter_dialog::open(app, &held.command).ok_or(tl!("The step has no command editor."))? + }; + let fields = &mut app.ui.dialog_mut(dialog).ok_or(tl!("The step editor could not be opened."))?.fields; + if let Some(params) = target.params.as_object() { + fields.extend(params.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); + } + fields.insert(MARK.into(), json!(true)); + held.dialog = dialog; + app.held_record_again = Some(held); + Ok(json!({"dialog": dialog})) +} + +/// Guard both actual preview routes before parsing LUT files or evaluating a command. +pub(crate) fn authorize_preview(app: &PhotocraftApp, dialog: u64, command: &str, params: &Value) -> Result<(), String> { + let Some(held) = app.held_record_again.as_ref().filter(|h| h.dialog == dialog) else { return Ok(()) }; + unchanged(app, held)?; + if command != held.command { + return Err(tl!("The Record Again command changed.").into()); + } + authorize(app, held, command, params) +} + +/// Apply through the registered engine command, retaining flags and replacing only after success. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_record_again.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No recorded step belongs to this editor.").into()); + } + let held = app.held_record_again.take().ok_or(tl!("No step is waiting."))?; + unchanged(app, &held)?; + if fields.get("__command").and_then(Value::as_str) != Some(held.command.as_str()) + || fields.get("__adjust").and_then(Value::as_str).is_some_and(|kind| format!("image.adjustments.{kind}") != held.command) + { + return Err(tl!("The Record Again command changed.").into()); + } + let params = crate::filter_dialog::params_of(fields); + let mut request = held.selection.clone(); + request["params"] = params.clone(); + authorize(app, &held, COMMAND, &request)?; + authorize(app, &held, &held.command, ¶ms)?; + let saved = app.session.authorize; + // Different policy functions must not replace the original caller's nested-command gate. + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Record Again again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, request); + app.session.authorize = saved; + app.filter_preview = None; + result +} + +/// A different dialog cannot discard this editor's selection or authority. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_record_again.as_ref().is_some_and(|held| held.dialog == dialog) { + app.held_record_again = None; + } +} diff --git a/crates/ui-egui/src/panels/actions/replace.rs b/crates/ui-egui/src/panels/actions/replace.rs index c339a921cf1..c79f12df3cd 100644 --- a/crates/ui-egui/src/panels/actions/replace.rs +++ b/crates/ui-egui/src/panels/actions/replace.rs @@ -24,18 +24,7 @@ pub(crate) fn owns(fields: &Map) -> bool { /// Apply both the request's authority and the current confirmation's authority. fn authorize(app: &PhotocraftApp, params: &Value, gate: Option, automated: bool) -> Result<(), String> { - if let Some(gate) = gate { - gate(COMMAND, params).map_err(|e| e.to_string())?; - } - if let Some(current) = app.session.authorize { - current(COMMAND, params).map_err(|e| e.to_string())?; - } - if (automated || app.automation_input || app.session.authorize.is_some()) - && let Some(check) = app.services.automation_command.as_ref() - { - check(COMMAND, params)?; - } - Ok(()) + super::authorize_owned(app, COMMAND, params, gate, automated) } /// A file picker or warning must not discard an active or suspended recording. diff --git a/crates/ui-egui/src/panels/actions/reset.rs b/crates/ui-egui/src/panels/actions/reset.rs new file mode 100644 index 00000000000..5f015e885d7 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/reset.rs @@ -0,0 +1,95 @@ +//! Reset offers explicit append/replace consent while default contents remain a placeholder. +use crate::{PhotocraftApp, state::DialogKind}; +use serde_json::{Map, Value, json}; +const COMMAND: &str = "actions.reset"; +const MARK: &str = "__resetActions"; + +/// The actual warning and reviewed library are private, not editable dialog fields. +pub(crate) struct Held { + dialog: u64, + revision: u64, + gate: Option, + automated: bool, +} + +/// Identify the fixed reset workflow through the protected route. +pub(crate) fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Reset must not discard a live or suspended recording. +fn idle(app: &PhotocraftApp) -> Result<(), String> { + super::suspended_guard(app, COMMAND)?; + if app.session.actions.recording.is_some() || app.session.actions.playing > 0 { + return Err(tl!("Stop recording or playback before resetting actions.").into()); + } + Ok(()) +} + +/// Capture this library and caller, keeping append as the initial choice. +pub(super) fn open(app: &mut PhotocraftApp) -> Result { + idle(app)?; + if app.held_actions_reset.is_some() { + return Err(tl!("Confirm or cancel the pending Actions reset first.").into()); + } + let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); + let automated = app.automation_input || gate.is_some(); + super::authorize_owned(app, COMMAND, &json!({"mode":"append","confirm":true}), gate, automated)?; + let fields = json!({MARK:true,"__command":COMMAND,"__label":"Reset Actions","mode":"append"}); + let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + app.held_actions_reset = Some(Held { dialog, revision: app.session.actions.rev, gate, automated }); + Ok(json!({"dialog":dialog})) +} + +/// Clearly label the temporary content and offer append, replace or the standard Cancel. +pub(crate) fn body(ui: &mut egui::Ui, fields: &mut Map) { + ui.label(tl!("Default Actions contents are not available yet. This restores an empty placeholder set.")); + let mut mode = fields.get("mode").and_then(Value::as_str).unwrap_or("append").to_string(); + ui.radio_value(&mut mode, "append".into(), tl!("Append")); + ui.radio_value(&mut mode, "replace".into(), tl!("Replace")); + if mode == "replace" { + ui.label(tl!("This removes the current action library and cannot be undone.")); + } + fields.insert("mode".into(), json!(mode)); +} + +/// Check actual consent and retained permissions before the registered command commits. +pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map) -> Result { + if app.held_actions_reset.as_ref().is_none_or(|h| h.dialog != dialog) { + return Err(tl!("No Actions reset belongs to this dialog.").into()); + } + let held = app.held_actions_reset.take().ok_or(tl!("No Actions reset is waiting."))?; + idle(app)?; + if held.revision != app.session.actions.rev || fields.get("__command").and_then(Value::as_str) != Some(COMMAND) { + return Err(tl!("The actions or reset command changed. Request Reset Actions again.").into()); + } + let params = json!({"mode":fields.get("mode"),"confirm":true}); + super::authorize_owned(app, COMMAND, ¶ms, held.gate, held.automated)?; + let saved = app.session.authorize; + if let (Some(old), Some(current)) = (held.gate, saved) + && !std::ptr::fn_addr_eq(old, current) + { + return Err(tl!("The command permissions changed. Request Reset Actions again.").into()); + } + app.session.authorize = held.gate.or(saved); + let result = app.run(COMMAND, params); + app.session.authorize = saved; + let result = result?; + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + app.ui.actions.selected_set = result.get("set").and_then(Value::as_str).map(str::to_string); + app.ui.actions.expanded.resize(app.session.actions.list.len(), false); + app.ui.actions.renaming = None; + app.ui.actions.reveal_recording = None; + if let Some(set) = &app.ui.actions.selected_set { + app.ui.actions.collapsed_sets.retain(|s| s != set); + } + Ok(result) +} + +/// Another dialog cannot discard this warning's consent. +pub(crate) fn cancel(app: &mut PhotocraftApp, dialog: u64) { + if app.held_actions_reset.as_ref().is_some_and(|h| h.dialog == dialog) { + app.held_actions_reset = None; + } +} diff --git a/crates/ui-egui/tests/it/actions_panel_menu.rs b/crates/ui-egui/tests/it/actions_panel_menu.rs new file mode 100644 index 00000000000..a29feb358ff --- /dev/null +++ b/crates/ui-egui/tests/it/actions_panel_menu.rs @@ -0,0 +1,217 @@ +//! Real panel controls compose the remaining commands with consent and retained selection. +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action, actions_edit_cmds, atn_cmds}; +use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, PhotocraftApp, actions, canvas, dialogs}; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{cell::RefCell, rc::Rc, sync::atomic::AtomicUsize}; + +/// A real panel and its dialogs with one selected action in a set. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":12})).unwrap(); + let mut a = Action::new("A", vec![]); + a.set = Some("S".into()); + app.session.actions.list.push(a); + app.ui.actions.selected = Some(0); + app.ui.actions.expanded = vec![true]; + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Click the actual menu entry and return its modal identity. +fn open(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> u64 { + h.get_by_label(label).click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +/// The checkbox changes recording policy rather than disabling actual painting. +#[test] +fn allow_tool_recording_checkbox_records_only_enabled_strokes() { + let mut h = harness(); + h.state_mut().run("actions.record", json!({"action":"A"})).unwrap(); + for enabled in [true, false] { + h.get_by_label("Allow Tool Recording").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.record_tools, enabled); + h.state_mut().run("paint.stroke", json!({"points":[[2,3],[10,3]],"size":2,"color":if enabled {"#ff0000"} else {"#00ff00"}})).unwrap(); + } + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[0].steps.len(), 1); + assert_eq!(h.state().session.actions.list[0].steps[0].1["color"], "#ff0000"); + assert_eq!(h.state().session.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 2); +} + +/// Folder selection dispatches set duplication and keeps the new folder selected, empty ones included. +#[test] +fn duplicate_selected_set_keeps_folder_and_disclosure_ownership() { + let mut h = harness(); + h.state_mut().ui.actions.selected = None; + h.state_mut().ui.actions.selected_set = Some("S".into()); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected, None); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("S copy")); + assert_eq!(actions_edit_cmds::set_names(&h.state().session), ["S", "S copy"]); + assert_eq!(h.state().ui.actions.expanded, [true, false]); + h.state_mut().run("actions.newSet", json!({"name":"Empty"})).unwrap(); + h.state_mut().ui.actions.selected_set = Some("Empty".into()); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.selected_set.as_deref(), Some("Empty copy")); +} + +/// Menu insertion uses an actual chooser and never samples or executes current command settings. +#[test] +fn insert_menu_item_chooser_cancels_or_records_descriptor_free_filter_event() { + let mut h = harness(); + let revision = h.state().session.active().unwrap().revision; + open(&mut h, "Insert Menu Item…"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().session.actions.list[0].steps.is_empty()); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("id".into(), json!("filter.blur.gaussianBlur")); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list[0].steps[0], (atn_cmds::MENU_ITEM.into(), json!({"id":"filter.blur.gaussianBlur","eventKind":"name"}))); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().ui.actions.selected_step, Some(0)); + let played = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = played["paused"]["dialog"].as_u64().unwrap(); + assert_eq!(h.state_mut().ui.dialog_mut(dialog).unwrap().fields["__command"], "filter.blur.gaussianBlur"); + dialogs::cancel(h.state_mut(), dialog).unwrap(); +} + +/// The selected path is snapshotted without applying it to the document. +#[test] +fn insert_path_uses_actual_selected_saved_path() { + let mut h = harness(); + h.get_by_label("Insert Path"); + assert!(h.state_mut().run("actions.insertPath", json!({"action":"A"})).is_err()); + h.state_mut().run("path.set", json!({"name":"Curve","path":{"subpaths":[{"closed":false,"knots":[{"anchor":[1,2],"out":[4,5]},[9,8]]}]}})).unwrap(); + h.state_mut().ui.selected_path = Some("Curve".into()); + h.run_steps(3); + let revision = h.state().session.active().unwrap().revision; + h.get_by_label("Insert Path").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list[0].steps[0].0, "path.set"); + assert_eq!(h.state().session.actions.list[0].steps[0].1["path"]["subpaths"][0]["knots"][0]["out"], json!([4.0, 5.0])); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} + +static REVOKED: AtomicBool = AtomicBool::new(false); + +/// A caller may lose permission while a destructive warning is open. +fn revocable(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if matches!(id, "actions.clearAll" | "actions.reset") && REVOKED.load(Ordering::SeqCst) { Err(EngineError::Other("revoked".into())) } else { Ok(()) } +} + +/// Actual append/replace/Cancel work, and retained or stale consent cannot clear the library. +#[test] +fn reset_placeholder_and_clear_all_keep_actual_consent_and_caller() { + let mut h = harness(); + let original = h.state().session.actions.list.clone(); + open(&mut h, "Reset Actions…"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, original); + open(&mut h, "Reset Actions…"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, original); + assert!(h.state().session.actions.sets.iter().any(|s| s.contains("placeholder"))); + open(&mut h, "Reset Actions…"); + h.get_by_label("Replace").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().session.actions.list.is_empty()); + assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); + for label in ["Reset Actions…", "Clear All Actions"] { + REVOKED.store(false, Ordering::SeqCst); + let mut h = harness(); + h.state_mut().session.authorize = Some(revocable); + let dialog = open(&mut h, label); + h.state_mut().session.authorize = None; + REVOKED.store(true, Ordering::SeqCst); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list.len(), 1); + REVOKED.store(false, Ordering::SeqCst); + h.run_steps(3); + let dialog = open(&mut h, label); + h.state_mut().run("actions.rename", json!({"action":"A","name":"Changed"})).unwrap(); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list[0].name, "Changed"); + } +} + +static PATH_CHECKS: AtomicUsize = AtomicUsize::new(0); + +/// A retained caller rejects edited map paths after the current request's gate is cleared. +fn deny_map_path(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id.starts_with("filter.") { + PATH_CHECKS.fetch_add(1, Ordering::SeqCst); + if params.get("mapPath").and_then(Value::as_str).is_some_and(|p| !p.is_empty()) { + return Err(EngineError::Other("map path denied".into())); + } + } + Ok(()) +} + +/// Real menu previews and Displace's later picker callback keep fixed ownership and authority. +#[test] +fn paused_menu_previews_and_picker_callbacks_keep_the_requesting_gate() { + let mut h = harness(); + h.state_mut().run("actions.insertMenuItem", json!({"action":"A","id":"filter.blur.gaussianBlur"})).unwrap(); + h.state_mut().session.authorize = Some(deny_map_path); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().session.authorize = None; + let context = h.ctx.clone(); + PATH_CHECKS.store(0, Ordering::SeqCst); + assert!(canvas::ensure_texture(h.state_mut(), &context, 0, None).is_some()); + assert!(PATH_CHECKS.load(Ordering::SeqCst) > 0, "the real permitted menu preview is authorized"); + let revision = h.state().session.active().unwrap().revision; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("mapPath".into(), json!("/outside/map.psd")); + PATH_CHECKS.store(0, Ordering::SeqCst); + canvas::ensure_texture(h.state_mut(), &context, 0, None); + assert!(PATH_CHECKS.load(Ordering::SeqCst) > 0, "edited path parameters are judged under the captured gate"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("filter.distort.displace")); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err(), "the editor cannot retarget to a path-reading command"); + dialogs::cancel(h.state_mut(), dialog).unwrap(); + assert_eq!(h.state().session.active().unwrap().revision, revision); + + let mut h = harness(); + let waiting: Rc>> = Default::default(); + let reply = waiting.clone(); + h.state_mut().services.file_dialog = Some(Box::new(move |_, _, answer| *reply.borrow_mut() = Some(answer))); + let a = &mut h.state_mut().session.actions.list[0]; + a.steps.push(("filter.distort.displace".into(), json!({}))); + a.dialogs.insert(0); + h.state_mut().session.authorize = Some(deny_map_path); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().session.authorize = None; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("mapDocument".into(), json!(-1)); + let revision = h.state().session.active().unwrap().revision; + dialogs::confirm(h.state_mut(), dialog).unwrap(); + let context = h.ctx.clone(); + h.state_mut().poll_file_dialog(&context, None); + waiting.borrow_mut().take().unwrap().send(Some(FileDialogAnswer::Paths(vec!["/outside/denied-map.psd".into()]))); + h.state_mut().poll_file_dialog(&context, None); + assert!(h.state().ui.status.contains("map path denied"), "{}", h.state().ui.status); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().session.actions.list[0].steps[0].1, json!({})); +} diff --git a/crates/ui-egui/tests/it/actions_record_again.rs b/crates/ui-egui/tests/it/actions_record_again.rs new file mode 100644 index 00000000000..dc91c67600e --- /dev/null +++ b/crates/ui-egui/tests/it/actions_record_again.rs @@ -0,0 +1,197 @@ +//! Record Again uses the real editor while retaining the selected step and permissions. +use std::sync::atomic::{AtomicUsize, Ordering}; + +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_engine::{EngineError, Session, actions_cmds::Action}; +use photocraft_ui_egui::{PhotocraftApp, actions, canvas, dialogs, state::DialogKind}; +use serde_json::{Value, json}; + +/// Draw the actual panel and dialogs over one selected command, without a GPU or file service. +fn harness(command: &str, params: Value) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(Session::new(), Default::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let mut action = Action::new("A", vec![(command.into(), params), ("select.all".into(), json!({}))]); + action.disabled.insert(0); + action.dialogs.insert(0); + app.session.actions.list.push(action); + app.ui.actions.selected = Some(0); + app.ui.actions.selected_step = Some(0); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +/// Open by clicking the panel entry, preserving its selected-step and enablement checks. +fn open(h: &mut Harness<'static, PhotocraftApp>) -> u64 { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Record Again").click(); + h.run_steps(3); + h.state().ui.dialogs.last().unwrap().id +} + +/// A resize with different recorded and document sizes exposes seeding from the wrong source. +fn sizing() -> Harness<'static, PhotocraftApp> { + harness("image.imageSize", json!({"width":4,"height":3,"resample":"nearest"})) +} + +/// Cancel preserves the step and document; OK runs only that step and stores its new settings. +#[test] +fn seeded_editor_cancels_or_updates_only_the_original_step() { + let mut h = sizing(); + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + let dialog = open(&mut h); + let fields = &h.state().ui.dialogs.last().unwrap().fields; + assert_eq!(fields["__sizing"], true); + assert_eq!(fields["width"].as_f64(), Some(4.0)); + assert_eq!(fields["height"].as_f64(), Some(3.0)); + assert_eq!(fields["__recordAgain"], true); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!(h.state().session.actions.list, original); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(h.state().ui.dialogs.iter().all(|d| d.id != dialog)); + let dialog = open(&mut h); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("width".into(), json!(6)); + fields.insert("height".into(), json!(5)); + fields.insert("__constrain".into(), json!(false)); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + let action = &h.state().session.actions.list[0]; + assert_eq!(action.steps.len(), 2); + assert_eq!(action.steps[0].1["width"].as_f64(), Some(6.0)); + assert!(action.disabled.contains(&0)); + assert!(action.dialogs.contains(&0)); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5)); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); +} + +/// Permit initial settings while denying a later edit even if the current gate is removed. +fn limited_resize(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" && params.get("width").and_then(Value::as_f64).is_some_and(|w| w >= 10.0) { + Err(EngineError::Other("resize denied".into())) + } else { + Ok(()) + } +} + +/// Changed definitions/documents, retained denials, invalid settings and retargeting cannot overwrite. +#[test] +fn stale_denied_or_failed_edit_preserves_the_stored_step() { + for mode in ["action-stale", "document-stale", "denied", "retargeted", "failed"] { + let mut h = sizing(); + if mode == "denied" { + h.state_mut().session.authorize = Some(limited_resize); + } + let dialog = open(&mut h); + match mode { + "action-stale" => { + h.state_mut().run("actions.rename", json!({"action":"A","name":"Changed"})).unwrap(); + } + "document-stale" => { + h.state_mut().run("file.new", json!({"width":3,"height":3})).unwrap(); + } + "denied" => { + h.state_mut().session.authorize = None; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("width".into(), json!(10)); + } + "retargeted" => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__command".into(), json!("select.all")); + } + _ => { + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("percent".into(), json!(-1)); + } + } + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + assert!(dialogs::confirm(h.state_mut(), dialog).is_err(), "{mode}"); + assert_eq!(h.state().session.actions.list, original, "{mode}"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "{mode}"); + h.run_steps(3); + let retry = open(&mut h); + dialogs::cancel(h.state_mut(), retry).unwrap(); + } +} + +/// Confirmation and Cancel of an impostor dialog leave the original editor's private state intact. +#[test] +fn another_dialog_cannot_consume_the_selected_step() { + let mut h = sizing(); + let real = open(&mut h); + let fields = h.state().ui.dialogs.last().unwrap().fields.clone(); + let fake = h.state_mut().ui.open_dialog(DialogKind::Command, fields.clone()); + assert!(dialogs::confirm(h.state_mut(), fake).is_err()); + let fake = h.state_mut().ui.open_dialog(DialogKind::Command, fields); + dialogs::cancel(h.state_mut(), fake).unwrap(); + dialogs::confirm(h.state_mut(), real).unwrap(); + assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_engine::doc::Size::new(4, 3)); +} + +static PREVIEW_CHECKS: AtomicUsize = AtomicUsize::new(0); + +/// Count actual preview authorization and refuse LUT paths or edited blur and gamma settings. +fn preview_gate(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if matches!(id, "filter.blur.gaussianBlur" | "image.adjustments.levels" | "image.adjustments.colorLookup") { + PREVIEW_CHECKS.fetch_add(1, Ordering::SeqCst); + if params.get("file").is_some() || ["radius", "gamma"].iter().any(|key| params.get(key).and_then(Value::as_f64).is_some_and(|v| v > 2.0)) { + return Err(EngineError::Other("preview denied".into())); + } + } + Ok(()) +} + +/// Both real preview routes check the retained caller before evaluating changed settings or LUT files. +#[test] +fn filter_and_adjustment_previews_retain_permissions_before_evaluation() { + for command in ["filter.blur.gaussianBlur", "image.adjustments.levels", "image.adjustments.colorLookup"] { + let params = if command.starts_with("filter.") { + json!({"radius":1}) + } else { + photocraft_engine::adjust_params::to_params( + &photocraft_engine::adjust_params::default_for(command.strip_prefix("image.adjustments.").unwrap(), photocraft_engine::doc::ColorMode::Rgb) + .unwrap(), + ) + }; + let mut h = harness(command, params); + h.state_mut().session.authorize = Some(preview_gate); + let dialog = open(&mut h); + h.state_mut().session.authorize = None; + let context = h.ctx.clone(); + PREVIEW_CHECKS.store(0, Ordering::SeqCst); + assert!(canvas::ensure_texture(h.state_mut(), &context, 0, None).is_some()); + assert!(PREVIEW_CHECKS.load(Ordering::SeqCst) > 0, "the permitted real preview request is authorized: {command}"); + if command == "image.adjustments.levels" { + assert!(dialogs::adjust_preview::display_doc(h.state_mut(), 0).is_some(), "the permitted adjustment preview is evaluated"); + } + let original = h.state().session.actions.list.clone(); + let revision = h.state().session.active().unwrap().revision; + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + if command.starts_with("filter.") { + fields.insert("radius".into(), json!(8)); + } else if command == "image.adjustments.levels" { + fields.insert("gamma".into(), json!(8)); + // This route parses settings even with Preview off; authorization must precede it. + fields.insert("__preview".into(), json!(false)); + } else { + fields.insert("file".into(), json!("/outside/forbidden.cube")); + } + PREVIEW_CHECKS.store(0, Ordering::SeqCst); + canvas::ensure_texture(h.state_mut(), &context, 0, None); + assert!(PREVIEW_CHECKS.load(Ordering::SeqCst) > 0, "the changed actual request was refused before evaluation: {command}"); + assert_eq!(h.state().session.actions.list, original); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list, original); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b06a9094d2f..bd3fe819adc 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -2,6 +2,8 @@ mod actions_clear_all; mod actions_duplicate; +mod actions_panel_menu; +mod actions_record_again; mod actions_replace; mod adjust_preview_gpu; mod arbitrary_rotation_preview; diff --git a/docs/development.md b/docs/development.md index 277d20b9540..3c0eb6ce344 100644 --- a/docs/development.md +++ b/docs/development.md @@ -631,6 +631,30 @@ PhotoCraft equivalent. Events outside the table load as `actions.photoshopStep` steps that keep the descriptor verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. +The Actions panel also exposes `actions.duplicate` for an action, set or step, and +`actions.recordAgain` to run one selected command with edited settings and replace that step +only after success. Record Again preserves its enabled/dialog flags. The panel seeds the +existing command editor; agents pass the new settings in `params` directly. + +`actions.insertMenuItem` records Image Size or an unambiguous mapped filter event without a +descriptor. Desktop playback opens its editor when settings are needed; headless playback +reports `interactive-required`. Stateless filter menu items run immediately. An absent +descriptor remains distinct from a present empty descriptor. `actions.allowToolRecording` +opts in to recording painting-tool strokes; changing it commits pending entries under the +previous mode. It does not suppress painting, history or journal entries. + +`actions.clearAll` and `actions.replace` require `confirm:true`; Replace validates the picked +file before changing the library and the panel confirms the exact bytes already picked. +Both remove empty folders too. Reset (`actions.reset`, `mode:"append"|"replace"`, +`confirm:true`) currently restores a clearly labeled **empty placeholder set**. The intended +Photoshop default contents are still awaiting clarification; this is not default-set parity. + +`actions.insertPath` snapshots the exact selected work, layer or saved path as a native +`path.set` work-path step, retaining cubic controls, operations, fill rule and inversion. +Its Photoshop `set`/`WrPt` descriptor mapping remains unsupported pending an independently +verified fill-rule representation. Save Actions refuses that native step rather than +writing a descriptor with guessed semantics. These two remaining parity gaps keep #10706 open. + `crates/psd/tests/it/atn_contract.rs` checks the parser's public API against four small, independently encoded fixtures in `crates/psd/tests/fixtures/atn/` (provenance and checksums in its README): `cargo test -p photocraft-psd --test it atn_contract::`. From 854f6ec9009317dcfd61ddcb9c82e9bcc0464e13 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Mon, 2 Nov 2026 00:40:54 -0200 Subject: [PATCH 060/384] retouch tests: a wrapped stroke has to change the pixels it covers The corner check passed on the pre-stroke texture, and the oversized-dab check passed on a no-op. Spot Healing stays excepted in the second one: it has nothing left to sample there. --- crates/engine/src/cmd/retouch_cmds/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 38fb98fdf37..6946df3ff39 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2699,8 +2699,9 @@ fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_noth let after = layer_pixels(&s); let n = after.len() / (WRAP.0 * WRAP.1) as usize; let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); - for (x, y) in [(1, 1), (46, 1), (1, 34), (46, 34)] { - assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); + assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); + for (x, y) in [(46, 1), (1, 34), (46, 34)] { + assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); } for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { assert_eq!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is far from the stroke"); @@ -2715,6 +2716,7 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { for (cmd, extra, setup, _) in cases { let mut s = session(16, 12, 8, "rgb"); setup(&mut s); + let before = layer_pixels(&s); let mut p = json!({"points": [[8, 6], [30, 20]], "size": 60, "hardness": 0, "wrap": true}); for (k, v) in extra.as_object().unwrap() { p[k] = v.clone(); @@ -2725,6 +2727,10 @@ fn a_wrapped_dab_bigger_than_the_canvas_stays_on_it() { let st = s.active().unwrap(); let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); assert!([(-1, 5), (16, 5), (5, -1), (5, 12), (40, 30)].iter().all(|&(x, y)| surf.rgba(x, y)[3] == 0.0), "{cmd}: nothing lands off the canvas"); + // Spot Healing has nothing left to sample here (the next test). + if cmd != "paint.spotHealing" { + assert!(layer_pixels(&s) != before, "{cmd} {extra}: the stroke changed the canvas"); + } } } From a7568b70b747786e027f3133b17434c18e01beed Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Mon, 2 Nov 2026 01:27:12 -0200 Subject: [PATCH 061/384] retouch tests: a wrapped corner has to heal as well as change --- crates/engine/src/cmd/retouch_cmds/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/cmd/retouch_cmds/tests.rs b/crates/engine/src/cmd/retouch_cmds/tests.rs index 6946df3ff39..ede2cb9348b 100644 --- a/crates/engine/src/cmd/retouch_cmds/tests.rs +++ b/crates/engine/src/cmd/retouch_cmds/tests.rs @@ -2701,6 +2701,7 @@ fn spot_healing_across_the_canvas_corner_heals_every_copy_of_the_stroke_and_noth let px = |v: &[f32], x: usize, y: usize| v[(y * WRAP.0 as usize + x) * n..][..n].to_vec(); assert!(px(&after, 1, 1)[0] > 0.1, "{kind}: the blemish healed: {:?}", px(&after, 1, 1)); for (x, y) in [(46, 1), (1, 34), (46, 34)] { + assert!(px(&after, x, y)[0] > 0.1, "{kind}: ({x}, {y}) healed: {:?}", px(&after, x, y)); assert_ne!(px(&after, x, y), px(&before, x, y), "{kind}: ({x}, {y}) is under the stroke"); } for (x, y) in [(24, 18), (10, 18), (24, 4), (38, 28)] { From 52ba921651368e2195dcbcd705648d5d7cfb6001 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:29:56 +0100 Subject: [PATCH 062/384] Test: Photo Filter remembers Preserve Luminosity turned off photo_filter_opens_at_its_last_settings only confirmed a filter and density, so Preserve Luminosity kept its default (on) and a regression dropping it would still pass. It now turns Preserve Luminosity off, confirms, reopens and checks it stays off with the rest of the last settings (CodeRabbit review of #9970). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/adjust_dialog/tests.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs index 1f6090025c0..a06c1dc8002 100644 --- a/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_dialog/tests.rs @@ -959,6 +959,15 @@ fn photo_filter_opens_at_its_last_settings() { let again = open(&mut app, cmd).unwrap(); assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()), sample, "reopened at the last settings"); + // Preserve Luminosity is on by default; turned off and confirmed, it reopens off. + assert_eq!(neutral["preserveLuminosity"], json!(true)); + app.ui.dialog_mut(again).unwrap().fields.insert("preserveLuminosity".into(), json!(false)); + crate::dialogs::confirm(&mut app, again).unwrap(); + let again = open(&mut app, cmd).unwrap(); + let reopened = crate::filter_dialog::params_of(&app.ui.dialog_mut(again).unwrap().fields.clone()); + assert_eq!(reopened["preserveLuminosity"], json!(false), "Preserve Luminosity off is remembered"); + assert_eq!(reopened["color"], sample["color"], "with the rest of the last settings"); + assert_eq!(reopened["density"], sample["density"]); app.ui.dialogs.clear(); let smart = open_neutral(&mut app, cmd).unwrap(); assert_eq!(crate::filter_dialog::params_of(&app.ui.dialog_mut(smart).unwrap().fields.clone()), neutral, "open_neutral ignores them"); From dec95112a06a56480b408a566b28adc9c5f6de53 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:53:33 +0100 Subject: [PATCH 063/384] Remove gradient stops dragged off the bar in every stop editor (#10742) Dragging a colour stop down, or an opacity stop up, by about a marker's height off the gradient bar now removes it on release in every stop editor, as in Photoshop's Gradient Editor. At least two stops of that kind always stay. While the stop is off the bar the ramp and the preview show it gone, and dragging it back onto the bar cancels. The Gradient Editor, Properties and shape gradient strips already did this. Gradient Map had no drag-off at all, and Layer Style's Gradient Overlay only deleted after about 44 pt and showed nothing until release. All four now share stop_dragged_off() in gradient_ui.rs (26 pt past the bar edge, by kind). Gradient Map and Gradient Overlay take the stop out live and put it back if it returns. Adds harness tests for drag-off removal, the two-stop minimum, opacity stops dragged up and drag-back cancel, and updates docs/ui-parity.md. Co-Authored-By: Claude Opus 5.5 --- .../dialogs/adjust_editors/gradient_map.rs | 41 +++++- .../src/dialogs/adjust_editors/ui_tests.rs | 49 +++++++ .../src/dialogs/layer_style/gradient_field.rs | 112 ++++++++++++--- crates/ui-egui/src/tools/gradient_ui.rs | 21 ++- .../src/tools/gradient_ui/editor_tests.rs | 133 ++++++++++++++++++ crates/ui-egui/src/tools/shape_gradient_ui.rs | 4 +- docs/ui-parity.md | 4 +- 7 files changed, 330 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs b/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs index 7079b119729..b15f06ba0c5 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/gradient_map.rs @@ -57,6 +57,9 @@ struct MapStopDrag { index: usize, offset: f32, changed: bool, + /// The dragged stop while it is pulled off the bar (removed from the stops, so the preview + /// shows it gone); dragging back onto the bar puts it back. + removed: Option, } /// Keep selection on the same stop when it crosses another stop (including coincident stops). @@ -87,19 +90,43 @@ fn gradient_map_strip(ui: &mut egui::Ui, stops: &mut Vec, cx: &EditorCx, s { *selected = index; let offset = stops.get(index).map_or(0.0, |s| pos.x - x_of(s.0)); - ui.data_mut(|d| d.insert_temp(key, MapStopDrag { index, offset, changed: false })); + ui.data_mut(|d| d.insert_temp(key, MapStopDrag { index, offset, changed: false, removed: None })); } if let Some(mut drag) = ui.data(|d| d.get_temp::(key)) { if (resp.dragged() || resp.drag_stopped()) && let Some(pos) = resp.interact_pointer_pos() { let location = round(loc_of(pos.x - drag.offset), 0.001); - let changed = stops.get(drag.index).is_some_and(|s| s.0 != location); - if let Some(index) = move_map_stop(stops, drag.index, location) { - drag.index = index; - drag.changed |= changed; - *selected = index; - edit.changed |= changed; + // Pulled down off the bar, the stop is removed while at least two remain, as in + // Photoshop; released there it stays deleted, dragged back it returns. + let off = crate::gradient_ui::stop_dragged_off(bar, pos, false); + match drag.removed { + None if off && stops.len() > 2 && drag.index < stops.len() => { + drag.removed = Some(stops.remove(drag.index)); + *selected = drag.index.min(stops.len().saturating_sub(1)); + drag.changed = true; + edit.changed = true; + } + Some(stop) if !off => { + stops.push(stop); + let index = stops.len() - 1; + drag.removed = None; + if let Some(index) = move_map_stop(stops, index, location) { + drag.index = index; + *selected = index; + } + edit.changed = true; + } + Some(_) => {} + None => { + let changed = stops.get(drag.index).is_some_and(|s| s.0 != location); + if let Some(index) = move_map_stop(stops, drag.index, location) { + drag.index = index; + drag.changed |= changed; + *selected = index; + edit.changed |= changed; + } + } } } if resp.drag_stopped() { diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index f41fe4eca01..bb0f7372659 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -560,6 +560,55 @@ fn gradient_map_strip_adds_selects_and_removes_stops_without_deleting_the_last_p assert_eq!(layer(&h).1, original, "keep two stops"); } +/// #10742: a Gradient Map stop dragged down off the bar is removed on release (one history step); +/// while it is off the bar the live preview already leaves it out, and dragging it back onto the +/// bar before release puts it back. The last two stops can't be dragged off. +#[test] +fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cancels() { + let mut h = app_harness("gradientMap", "rgb", 8); + let (id, _) = layer(&h); + h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "stops": [[0, "#000000"], [0.5, "#ff0000"], [1, "#ffffff"]]})).unwrap(); + h.run_steps(3); + let three = layer(&h).1; + let live_stops = |h: &Harness<'_, PhotocraftApp>| h.state().live_adjust.as_ref().and_then(|(_, v)| v["stops"].as_array().map(Vec::len)); + // Off and back: nothing removed. + let start = map_handle(&h, 0.5); + let away = start + vec2(0.0, 30.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(start + vec2(0.0, 15.0)); + h.run_steps(1); + h.hover_at(away); + h.run_steps(1); + assert_eq!(live_stops(&h), Some(2), "off the bar the preview leaves the stop out"); + h.hover_at(start); + h.run_steps(1); + assert_eq!(live_stops(&h), Some(3), "back on the bar the stop returns"); + h.drop_at(start); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), map_stops(&three), "dragging back cancels the removal"); + // Off and released: removed in one step. + let steps = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, start, away); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 0, 0]), (1.0, [255, 255, 255])]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + assert!(h.state().live_adjust.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(layer(&h).1, three); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(3); + // Two stops stay. + let two = layer(&h).1; + for at in [0.0, 1.0] { + let from = map_handle(&h, at); + drag(&mut h, from, from + vec2(0.0, 40.0)); + assert_eq!(map_stops(&layer(&h).1), map_stops(&two), "two stops always remain"); + } +} + #[test] fn gradient_map_drag_clamps_at_the_endpoints_and_reverse_keeps_stop_coordinates() { let mut h = app_harness("gradientMap", "rgb", 8); diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs index 8abbefbbd7b..c59b666b3f1 100644 --- a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs @@ -23,6 +23,16 @@ pub(super) const KEY: &str = "stops"; /// The dialog field holding the transparency stops. pub(super) const OPACITY_KEY: &str = "opacityStops"; +/// A colour stop being dragged on the page's ramp. +#[derive(Clone, Copy, Debug)] +struct StopDrag { + /// The stop's index (it changes as the stop passes others). + index: usize, + /// The stop while it is pulled off the bar: deleted from the gradient, so the ramp and the + /// canvas show it gone; dragging back onto the bar puts it back. + removed: Option<(f32, Color)>, +} + /// Where the last drawn ramp bar is, for tests that drive it: `(effect id, bar rect)`. pub(crate) fn bar_id() -> egui::Id { egui::Id::unique("fx-gradient-bar") @@ -207,39 +217,64 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); + ui.data_mut(|d| d.remove::(drag_key)); } } - let dragging: Option = ui.data(|d| d.get_temp(drag_key)); - if let (Some(i), Some(p)) = (dragging, resp.interact_pointer_pos()) { - let away = p.y > outer.bottom() + 24.0 || p.y < bar.top() - 24.0; + let dragging: Option = ui.data(|d| d.get_temp(drag_key)); + if let (Some(mut drag), Some(p)) = (dragging, resp.interact_pointer_pos()) { + let off = crate::gradient_ui::stop_dragged_off(bar, p, false); + let loc = loc_of(p.x); if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(drag_key)); - if away && stops.len() > 2 { - edit(app, f, effect, disp, json!({"action": "delete", "index": i})); - ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); - } - } else if resp.dragged() && !away { - let loc = loc_of(p.x); - if (loc - stops.get(i).map_or(loc, |s| s.0)).abs() > 1e-4 - && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": i, "location": loc})) - { - ui.data_mut(|d| d.insert_temp(drag_key, at)); - ui.data_mut(|d| d.insert_temp(sel_key, at)); + // Released off the strip, the stop is already gone. + ui.data_mut(|d| d.remove::(drag_key)); + } else if resp.dragged() { + match drag.removed { + None if off && stops.len() > 2 => { + if let Some(stop) = stops.get(drag.index).copied() + && edit(app, f, effect, disp, json!({"action": "delete", "index": drag.index})).is_some() + { + drag.removed = Some(stop); + ui.data_mut(|d| d.insert_temp(sel_key, 0usize)); + } + } + Some((_, color)) if !off => { + let mut back = stops.clone(); + back.push((loc, color)); + back.sort_by(|a, b| a.0.total_cmp(&b.0)); + let index = back.iter().rposition(|s| s.0 == loc).unwrap_or(0); + let mut fill = fill.clone(); + if let Fill::Gradient { stops, .. } = &mut fill { + *stops = back; + } + commit(f, effect, disp, &fill); + drag = StopDrag { index, removed: None }; + ui.data_mut(|d| d.insert_temp(sel_key, index)); + } + Some(_) => {} + None => { + if (loc - stops.get(drag.index).map_or(loc, |s| s.0)).abs() > 1e-4 + && let Some(at) = edit(app, f, effect, disp, json!({"action": "move", "index": drag.index, "location": loc})) + { + drag.index = at; + ui.data_mut(|d| d.insert_temp(sel_key, at)); + } + } } + ui.data_mut(|d| d.insert_temp(drag_key, drag)); } } else if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(drag_key)); + ui.data_mut(|d| d.remove::(drag_key)); } if resp.double_clicked() && let Some(p) = resp.interact_pointer_pos() @@ -385,6 +420,41 @@ mod tests { assert_eq!(stops_in(h.state(), parent).len(), 2); } + /// #10742: on the Gradient Overlay page a colour stop dragged down off the bar (about a + /// marker's height) disappears while it is off, is gone on release, and comes back when + /// dragged back onto the bar first. + #[test] + fn a_stop_dragged_off_the_ramp_shows_gone_and_dragging_back_cancels() { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + let away = pos2(mid.x, mid.y + 22.0); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(stops_in(h.state(), parent).len(), 2, "off the bar the stop is gone"); + h.event(egui::Event::PointerMoved(mid)); + h.run_steps(1); + let back = stops_in(h.state(), parent); + assert_eq!(back.len(), 3, "back on the bar it returns: {back:?}"); + release(&mut h, mid); + let back = stops_in(h.state(), parent); + assert!(back.len() == 3 && (back[1].0 - 0.5).abs() < 0.01 && super::super::hex(&back[1].1) == "#ff0000", "{back:?}"); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + release(&mut h, away); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.iter().map(|s| super::super::hex(&s.1)).collect::>(), ["#000000", "#ffffff"], "{stops:?}"); + } + /// A preset from the Gradients panel becomes the page's stops; the Delete button and the /// Location field work on the selected stop. #[test] diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index 1978f270868..12d2faeedd0 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -516,6 +516,19 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// How far past the gradient bar's edge (in points) a dragged stop comes off it: the stop marker +/// (about 15 pt) plus roughly its height again, as in Photoshop's Gradient Editor. +pub(crate) const STOP_OFF_BAR: f32 = 26.0; + +/// Whether a stop dragged to `pos` is pulled off `bar`, as in Photoshop's Gradient Editor: a +/// colour stop (under the bar) dragged down, an opacity stop (above it) dragged up, about a +/// marker's height past the marker. Every stop strip uses this, so the gesture is the same in +/// the Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style. +/// The caller removes the stop only while more than two of its kind remain. +pub(crate) fn stop_dragged_off(bar: Rect, pos: Pos2, opacity: bool) -> bool { + if opacity { pos.y < bar.top() - STOP_OFF_BAR } else { pos.y > bar.bottom() + STOP_OFF_BAR } +} + // ---------------------------------------------------------------- options bar /// A ramp of RGBA stops over a checkerboard in `r`. @@ -995,12 +1008,14 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let dragging: Option = ui.data(|d| d.get_temp(key)); let mut preview = None; if let (Some(m), Some(pos)) = (dragging, resp.interact_pointer_pos()) { - let off = if pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0 { 1.0 } else { 0.0 }; + // Pulled off the bar, the stop is deleted (the preview shows it gone; dragging back + // cancels), while at least two of its kind remain. + let off = stop_dragged_off(bar, pos, matches!(m, Marker::Opacity(_))); let loc = loc_of(pos.x); let edit = match m { - Marker::Color(i) if off > 0.0 && stops.len() > 2 => json!({"action": "delete", "index": i}), + Marker::Color(i) if off && stops.len() > 2 => json!({"action": "delete", "index": i}), Marker::Color(i) => json!({"action": "move", "index": i, "location": loc}), - Marker::Opacity(i) if off > 0.0 && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), + Marker::Opacity(i) if off && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), Marker::Opacity(i) => json!({"action": "move", "kind": "opacity", "index": i, "location": loc}), Marker::Mid(i) => { let (a, b) = (sorted.get(i).map_or(0.0, |s| s.0), sorted.get(i + 1).map_or(1.0, |s| s.0)); diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index 8bc5019ddf0..c761838f365 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -144,3 +144,136 @@ fn preset_editor_opacity_fields_do_not_edit_document_history() { assert_eq!(h.state().session.active().unwrap().doc, before); assert_eq!(h.state().session.active().unwrap().history.past_len(), history); } + +/// Press at `points[0]`, move through the rest, then release at the last one; `during` runs while +/// the button is still down at the last point. +fn drag_through(h: &mut Harness, points: &[Pos2], during: impl FnOnce(&mut Harness)) { + let (Some(&from), Some(&to)) = (points.first(), points.last()) else { return }; + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for &p in &points[1..] { + // Two small steps per leg, as a real pointer reports. + let last = h.ctx.input(|i| i.pointer.latest_pos()).unwrap_or(p); + h.event(egui::Event::PointerMoved(last + (p - last) * 0.5)); + h.run_steps(1); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + during(h); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// A stop marker's centre on the strip: colour stops under the bar, opacity stops above it. +fn marker(h: &Harness, location: f32, opacity: bool) -> Pos2 { + let rect = h.get_by_label("Gradient stops").rect(); + pos2(rect.left() + 8.0 + location * (rect.width() - 16.0), rect.top() + if opacity { 6.0 } else { 50.0 }) +} + +fn counts(h: &Harness, sink: Sink) -> (usize, usize) { + let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + (stops.len(), opacity_stops.len()) +} + +/// The strip as drawn while the pointer is down: the pending edit applied to the gradient. +fn shown(h: &Harness, sink: Sink) -> (usize, usize) { + let f = sink.fill(h.state()).unwrap(); + let pending = match sink { + Sink::Layer(_) => h.state().gradient.panel.as_ref().map(|(.., p)| p.clone()), + Sink::Preset => h.state().gradient.preset_stop.clone(), + }; + let (fg, bg) = (h.state().session.tools.foreground, h.state().session.tools.background); + let f = pending.and_then(|p| cmds::apply_stop(&f, &p, fg, bg).ok()).unwrap_or(f); + let Fill::Gradient { stops, opacity_stops, .. } = f else { panic!() }; + (stops.len(), opacity_stops.len()) +} + +/// #10742: a colour stop dragged down off the bar (by about a marker's height) is removed on +/// release; while it is off the bar the strip already shows it gone; one undo step restores it. +#[test] +fn a_color_stop_dragged_down_off_the_bar_is_removed() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let history = h.state().session.active().unwrap().history.past_len(); + let before = sink.fill(h.state()).unwrap(); + let from = marker(&h, 0.5, false); + drag_through(&mut h, &[from, from + vec2(0.0, 22.0)], |h| { + assert_eq!(shown(h, sink), (2, 3), "layer {layer}: the dragged-off stop is gone from the preview"); + assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: nothing is committed before release"); + }); + let Fill::Gradient { stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + assert_eq!(stops.iter().map(|s| s.0).collect::>(), [0.0, 1.0], "layer {layer}: the middle stop is removed"); + assert_eq!(counts(&h, sink), (2, 3), "layer {layer}: opacity stops are untouched"); + if layer { + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1, "one undo step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(sink.fill(h.state()).unwrap(), before); + } + } +} + +/// Dragging a stop off the bar and back onto it before releasing cancels the removal. +#[test] +fn dragging_a_stop_back_onto_the_bar_cancels_the_removal() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + for opacity in [false, true] { + let from = marker(&h, 0.5, opacity); + let away = from + vec2(0.0, if opacity { -30.0 } else { 30.0 }); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + (away - from) * 0.5)); + h.run_steps(1); + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(shown(&h, sink), if opacity { (3, 2) } else { (2, 3) }, "layer {layer}: off the bar the stop is gone"); + // Back on the bar before release: the stop returns and stays where it was. + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + assert_eq!(shown(&h, sink), (3, 3), "layer {layer}: back on the bar, the stop is shown"); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + let Fill::Gradient { stops, opacity_stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + let locations: Vec = if opacity { opacity_stops.iter().map(|s| s.0).collect() } else { stops.iter().map(|s| s.0).collect() }; + assert_eq!((stops.len(), opacity_stops.len()), (3, 3), "layer {layer} opacity {opacity}: nothing removed"); + assert!((locations[1] - 0.5).abs() < 1e-3, "layer {layer} opacity {opacity}: {locations:?}"); + } + } +} + +/// The last two colour stops stay: dragging one off the bar leaves it where it is. +#[test] +fn the_last_two_color_stops_cannot_be_dragged_off() { + let (mut h, sink) = editor(true); + let from = marker(&h, 0.5, false); + drag_through(&mut h, &[from, from + vec2(0.0, 60.0)], |_| {}); + assert_eq!(counts(&h, sink), (2, 3)); + let after = sink.fill(h.state()).unwrap(); + for location in [0.0, 1.0] { + let from = marker(&h, location, false); + drag_through(&mut h, &[from, from + vec2(0.0, 60.0)], |h| assert_eq!(shown(h, sink).0, 2, "the preview keeps two stops")); + assert_eq!(sink.fill(h.state()).unwrap(), after, "two colour stops always remain"); + } +} + +/// An opacity stop dragged up off the bar is removed; dragging it down over the ramp is not. +#[test] +fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, true); + drag_through(&mut h, &[from, from - vec2(0.0, 30.0)], |h| assert_eq!(shown(h, sink), (3, 2), "layer {layer}")); + let Fill::Gradient { opacity_stops, stops, .. } = sink.fill(h.state()).unwrap() else { panic!() }; + assert_eq!(opacity_stops.iter().map(|s| s.0).collect::>(), [0.0, 1.0], "layer {layer}"); + assert_eq!(stops.len(), 3, "layer {layer}: colour stops are untouched"); + // Two opacity stops remain: the next one dragged up stays. + let before = sink.fill(h.state()).unwrap(); + let from = marker(&h, 0.0, true); + drag_through(&mut h, &[from, from - vec2(0.0, 30.0)], |_| {}); + assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: two opacity stops always remain"); + } +} diff --git a/crates/ui-egui/src/tools/shape_gradient_ui.rs b/crates/ui-egui/src/tools/shape_gradient_ui.rs index e2fbfdb679e..c4a6c9068ad 100644 --- a/crates/ui-egui/src/tools/shape_gradient_ui.rs +++ b/crates/ui-egui/src/tools/shape_gradient_ui.rs @@ -347,8 +347,8 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ let (start_cs, start_os, _) = sorted_stops(&drag.start); let pos = resp.interact_pointer_pos(); if let Some(pos) = pos { - // Dragged well off the strip, a stop is deleted (while at least two remain), as in Photoshop. - let off = pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0; + // Dragged off the strip, a stop is deleted (while at least two remain), as in Photoshop. + let off = crate::gradient_ui::stop_dragged_off(bar, pos, matches!(drag.marker, Marker::Opacity(_))); let loc = loc_of(pos.x); let edit = match drag.marker { Marker::Color(i) if off && start_cs.len() > 2 => json!({"action": "delete", "index": i}), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 56f26760e1f..8bc4974646c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -240,6 +240,7 @@ Paragraph; the rest open as in-app floating windows. | Color panel modes from its panel menu (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), colour ramp (RGB/CMYK Spectrum, Grayscale Ramp, Current Colors, Make Ramp Web Safe; Shift-click cycles, Alt-click picks the other colour), Copy Color as HTML / Hex Code; the out-of-gamut ⚠ with the nearest printable colour under the chips (sliders always; the cubes when the field is at least 53 pt tall); the same in every theme | done | `color_panel_ui.rs`, `color_panel_tests.rs`; UI-217-17, #2569, #6000, #8672 | | A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; a swatch click recolours the selected type layers and shape fills whatever the tool (#9238); one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | | | The Type tool's Color Picker (Text Color) and a gradient fill's Color Picker (Stop Color) preview the picked colour on the canvas before OK (the selected characters, every selected type layer, or the stop); Cancel restores the document with no history step, OK commits the previewed edit in one step (#5530) | done: `dialogs/color_picker_preview.rs`, `dialogs/color_picker_preview/tests.rs` | | +| Gradient stops come off the bar: a colour stop dragged down, or an opacity stop dragged up, by about a marker's height is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `stop_dragged_off`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | | Window size and dock width remembered | done | UI-217-2 | | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | @@ -266,6 +267,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Gradient stops dragged off the bar are removed, with a live preview and drag-back cancel, in every stop editor: Gradient Map and Layer Style gained it, the Gradient Editor, Properties and shape popover share the same threshold (#10742); row added (done); no percentage changed | | 2026-11-01 | minor | Every preference takes effect: 11 of 183 do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see | | 2026-10-29 | minor | Paragraph box overflow: text a box can't hold enlarges the box's lower-right handle and shows a black + in it, as the reference application does (#8311); agents read `typeBoxOverflow` in `ui.inspect`. Not a new command, so no percentage changed | | 2026-10-28 | minor | The text and gradient stop Color Pickers preview the picked colour on the canvas before OK, as Photoshop's do (#5530); no percentage changed | From 2d30a2b669ae008915d02633bbcf0fa16ba09568 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:37:01 +0100 Subject: [PATCH 064/384] Gradient stops: the release frame decides drag-off; review fixes (#10742) The Gradient Editor/Properties strip and Layer Style's Gradient Overlay ramp ignored the release frame. When the last pointer move came in the same frame as the release, the stop stayed in the state the previous frame left it in. A stop flicked off the bar was kept, and one brought back onto the bar at release was deleted. Both now apply the same drag-off rule on that frame, as Gradient Map and the shape strip already did. The editor strip also counted opacity stops from its preview, which already leaves a dragged-off stop out. So while an opacity stop was off the bar, the preview flipped between deleting and moving it every frame. It now counts the committed stops, like colour stops. Tests: a quick release off the bar and back onto it (both editors); colour stops dragged up and opacity stops dragged down are kept; a cancelled Gradient Map drag adds no history step. The parity row names Gradient Map's control "Remove stop". Co-Authored-By: Claude Opus 5.5 --- .../src/dialogs/adjust_editors/ui_tests.rs | 2 + .../src/dialogs/layer_style/gradient_field.rs | 44 +++++++++++++-- crates/ui-egui/src/tools/gradient_ui.rs | 10 ++-- .../src/tools/gradient_ui/editor_tests.rs | 54 ++++++++++++++++++- docs/ui-parity.md | 2 +- 5 files changed, 102 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs index bb0f7372659..625039a6925 100644 --- a/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/adjust_editors/ui_tests.rs @@ -570,6 +570,7 @@ fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cance h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "stops": [[0, "#000000"], [0.5, "#ff0000"], [1, "#ffffff"]]})).unwrap(); h.run_steps(3); let three = layer(&h).1; + let before_cancel = h.state().session.active().unwrap().history.past_len(); let live_stops = |h: &Harness<'_, PhotocraftApp>| h.state().live_adjust.as_ref().and_then(|(_, v)| v["stops"].as_array().map(Vec::len)); // Off and back: nothing removed. let start = map_handle(&h, 0.5); @@ -589,6 +590,7 @@ fn gradient_map_stop_dragged_down_off_the_bar_is_removed_and_dragging_back_cance h.drop_at(start); h.run_steps(3); assert_eq!(map_stops(&layer(&h).1), map_stops(&three), "dragging back cancels the removal"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before_cancel, "a cancelled drag adds no history step"); // Off and released: removed in one step. let steps = h.state().session.active().unwrap().history.past_len(); drag(&mut h, start, away); diff --git a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs index c59b666b3f1..e5e253caab7 100644 --- a/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs +++ b/crates/ui-egui/src/dialogs/layer_style/gradient_field.rs @@ -235,10 +235,9 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); - } else if resp.dragged() { + // The release frame applies the same rule (its last move may cross the edge), then ends + // the drag. + if resp.dragged() || resp.drag_stopped() { match drag.removed { None if off && stops.len() > 2 => { if let Some(stop) = stops.get(drag.index).copied() @@ -271,7 +270,11 @@ pub(super) fn row(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map(drag_key)); + } else { + ui.data_mut(|d| d.insert_temp(drag_key, drag)); + } } } else if resp.drag_stopped() { ui.data_mut(|d| d.remove::(drag_key)); @@ -455,6 +458,37 @@ mod tests { assert_eq!(stops.iter().map(|s| super::super::hex(&s.1)).collect::>(), ["#000000", "#ffffff"], "{stops:?}"); } + /// The release frame counts: a stop that leaves the strip in the frame it is released is + /// removed, and one brought back onto the strip in that frame is kept. + #[test] + fn the_release_frame_decides_whether_a_stop_comes_off_the_ramp() { + for back in [true, false] { + let (mut app, parent) = dialog_app(); + let effect = crate::layer_style::effects_of(&app.ui.dialogs[0].fields)[0]["id"].as_str().unwrap().to_string(); + set_param(&mut app.ui.dialog_mut(parent).unwrap().fields, &effect, KEY, json!([[0.0, "#000000"], [0.5, "#ff0000"], [1.0, "#ffffff"]])); + let mut h = harness(app, parent); + let (_, bar): (String, Rect) = h.ctx.data(|d| d.get_temp(bar_id())).expect("the ramp was drawn"); + let mid = pos2(bar.center().x, bar.bottom() + 9.0); + let away = pos2(mid.x, mid.y + 30.0); + press(&mut h, mid); + h.event(egui::Event::PointerMoved(pos2(mid.x, mid.y + 11.0))); + h.run_steps(1); + if back { + h.event(egui::Event::PointerMoved(away)); + h.run_steps(1); + assert_eq!(stops_in(h.state(), parent).len(), 2, "off the strip the stop is gone"); + } + let to = if back { mid } else { away }; + // The last move and the release in one frame (`Harness::event` gives each its own). + let input = &mut h.input_mut().events; + input.push(egui::Event::PointerMoved(to)); + input.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let stops = stops_in(h.state(), parent); + assert_eq!(stops.len(), if back { 3 } else { 2 }, "back {back}: {stops:?}"); + } + } + /// A preset from the Gradients panel becomes the page's stops; the Delete button and the /// Location field work on the selected stop. #[test] diff --git a/crates/ui-egui/src/tools/gradient_ui.rs b/crates/ui-egui/src/tools/gradient_ui.rs index 12d2faeedd0..daa9a9344f9 100644 --- a/crates/ui-egui/src/tools/gradient_ui.rs +++ b/crates/ui-egui/src/tools/gradient_ui.rs @@ -903,7 +903,10 @@ fn selection_after_edit(f: &Fill, p: &Value, previous: Option) -> Option } fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pending: Option<&Value>, colors: ([f32; 4], [f32; 4])) -> Option { - let Fill::Gradient { stops, .. } = f else { return None }; + // The stops as committed: a drag edit (and the two-stop minimum) addresses these, not the + // pending preview, which may already leave the dragged stop out. + let Fill::Gradient { stops, opacity_stops, .. } = f else { return None }; + let opacity_count = if opacity_stops.is_empty() { 2 } else { opacity_stops.len() }; let t = Tokens::get(ui.ctx()); let w = ui.available_width().max(120.0); let (outer, resp) = ui.allocate_exact_size(vec2(w, 58.0), Sense::click_and_drag()); @@ -1015,14 +1018,15 @@ fn stop_strip(ui: &mut egui::Ui, key: egui::Id, f: &Fill, midpoints: bool, pendi let edit = match m { Marker::Color(i) if off && stops.len() > 2 => json!({"action": "delete", "index": i}), Marker::Color(i) => json!({"action": "move", "index": i, "location": loc}), - Marker::Opacity(i) if off && ops.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), + Marker::Opacity(i) if off && opacity_count > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), Marker::Opacity(i) => json!({"action": "move", "kind": "opacity", "index": i, "location": loc}), Marker::Mid(i) => { let (a, b) = (sorted.get(i).map_or(0.0, |s| s.0), sorted.get(i + 1).map_or(1.0, |s| s.0)); json!({"action": "midpoint", "index": i, "location": ((loc - a) / (b - a).max(1e-4)).clamp(0.05, 0.95)}) } }; - if resp.dragged() { + // The release frame counts too: its own last move decides whether the stop comes off. + if resp.dragged() || resp.drag_stopped() { preview = Some(edit); } } diff --git a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs index c761838f365..34636bd376f 100644 --- a/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs +++ b/crates/ui-egui/src/tools/gradient_ui/editor_tests.rs @@ -260,7 +260,7 @@ fn the_last_two_color_stops_cannot_be_dragged_off() { } } -/// An opacity stop dragged up off the bar is removed; dragging it down over the ramp is not. +/// An opacity stop dragged up off the bar is removed while two remain. #[test] fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { for layer in [true, false] { @@ -277,3 +277,55 @@ fn an_opacity_stop_dragged_up_off_the_bar_is_removed() { assert_eq!(sink.fill(h.state()).unwrap(), before, "layer {layer}: two opacity stops always remain"); } } + +/// Only the stop's own direction takes it off: an opacity stop dragged down over the ramp and a +/// colour stop dragged up over it are kept, however far they go. +#[test] +fn a_stop_dragged_the_other_way_is_kept() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + for opacity in [true, false] { + let from = marker(&h, 0.5, opacity); + let to = from + vec2(0.0, if opacity { 70.0 } else { -70.0 }); + drag_through(&mut h, &[from, to], |h| assert_eq!(shown(h, sink), (3, 3), "layer {layer} opacity {opacity}: still shown")); + assert_eq!(counts(&h, sink), (3, 3), "layer {layer} opacity {opacity}: the stop is kept"); + } + } +} + +/// Press on a stop, drag a little along the bar, then move to each of `then` in its own frame; +/// the last move and the release arrive in the same frame, as a quick flick does. +fn flick(h: &mut Harness, from: Pos2, then: &[Pos2]) { + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from + vec2(10.0, 0.0))); + h.run_steps(1); + let Some((&last, moves)) = then.split_last() else { return }; + for &p in moves { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + // `Harness::event` runs one frame per event, so both go straight into the next frame. + let input = &mut h.input_mut().events; + input.push(egui::Event::PointerMoved(last)); + input.push(egui::Event::PointerButton { pos: last, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// The release frame counts: a stop flicked off the bar and released in the same frame is +/// removed, and one brought back onto the bar in the release frame is kept. +#[test] +fn the_release_frame_decides_whether_the_stop_comes_off() { + for layer in [true, false] { + let (mut h, sink) = editor(layer); + let from = marker(&h, 0.5, false); + let away = from + vec2(10.0, 40.0); + flick(&mut h, from, &[away, from]); + assert_eq!(counts(&h, sink), (3, 3), "layer {layer}: back on the bar at release, the stop is kept"); + let from = marker(&h, 0.5, false); + flick(&mut h, from, &[from + vec2(10.0, 40.0)]); + assert_eq!(counts(&h, sink), (2, 3), "layer {layer}: released off the bar in the same frame, the stop is removed"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8bc4974646c..abf9431ef41 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -240,7 +240,7 @@ Paragraph; the rest open as in-app floating windows. | Color panel modes from its panel menu (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), colour ramp (RGB/CMYK Spectrum, Grayscale Ramp, Current Colors, Make Ramp Web Safe; Shift-click cycles, Alt-click picks the other colour), Copy Color as HTML / Hex Code; the out-of-gamut ⚠ with the nearest printable colour under the chips (sliders always; the cubes when the field is at least 53 pt tall); the same in every theme | done | `color_panel_ui.rs`, `color_panel_tests.rs`; UI-217-17, #2569, #6000, #8672 | | A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; a swatch click recolours the selected type layers and shape fills whatever the tool (#9238); one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | | | The Type tool's Color Picker (Text Color) and a gradient fill's Color Picker (Stop Color) preview the picked colour on the canvas before OK (the selected characters, every selected type layer, or the stop); Cancel restores the document with no history step, OK commits the previewed edit in one step (#5530) | done: `dialogs/color_picker_preview.rs`, `dialogs/color_picker_preview/tests.rs` | | -| Gradient stops come off the bar: a colour stop dragged down, or an opacity stop dragged up, by about a marker's height is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `stop_dragged_off`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | +| Gradient stops come off the bar: a colour stop dragged down, or an opacity stop dragged up, by about a marker's height is removed on release while two of its kind remain; while it is off the ramp and canvas preview show it gone, and dragging it back onto the bar cancels; the selected stop's Delete button (Remove stop, the × beside the colour, in Gradient Map) does the same. Gradient Editor, Properties, the shape gradient popover, Gradient Map and Layer Style (Gradient Overlay) share one rule (#10742) | done: `tools/gradient_ui.rs` `stop_dragged_off`, `tools/gradient_ui/editor_tests.rs`, `adjust_editors/ui_tests.rs`, `layer_style/gradient_field.rs`. Differs: Gradient Map and Layer Style show colour stops only; no Delete-key shortcut for the selected stop | | | Window size and dock width remembered | done | UI-217-2 | | Histogram panel: Compact (the default, of the entire image), Expanded and All Channels views (the channel kept across them); RGB/CMYK/Lab/Gray channels, Luminosity and Colors (overlaid, mixed where they overlap); Show Channels in Color and Show Statistics; Source (Entire Image, Selected Layer, Adjustment Composite) for multilayer documents; Mean, Std Dev, Median, Pixels, Level, Count, Percentile and Cache Level for the pointer level or a dragged range; the cached-data warning, Uncached Refresh (menu, button, warning click, double-click); the adjusted histogram over the original while an adjustment previews; Cache Levels 1–8 in Preferences › Performance; Color Sampler levels marked on the graph | partial | `histogram_panel.rs`, `histogram_panel_tests.rs`, `tone.rs`; differs: filter dialogs that preview on the CPU proxy aren't counted live, a selection doesn't limit the histogram yet, with Source Selected Layer an adjustment dialog's preview isn't counted, and new strings are English-only so far | | Command palette with recents | done | UI-217-7 | From dd1f34a57df5eb8d32ff8dc47b5f65e71ddebd7a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 11:07:35 -0800 Subject: [PATCH 065/384] fix: preserve Actions dialog ownership and picker limits --- crates/format/src/read.rs | 6 + crates/format/tests/it/main.rs | 2 + crates/format/tests/it/read_limits.rs | 17 ++ crates/ui-egui/src/control.rs | 4 + crates/ui-egui/src/file_dialog.rs | 31 +++- crates/ui-egui/src/file_dialog/tests.rs | 41 +++++ crates/ui-egui/src/panels/actions.rs | 23 ++- .../ui-egui/src/panels/actions/menu_item.rs | 3 - crates/ui-egui/src/panels/actions/replace.rs | 7 +- crates/ui-egui/src/panels/actions/reset.rs | 9 +- crates/ui-egui/tests/it/actions_clear_all.rs | 21 +++ crates/ui-egui/tests/it/actions_panel_menu.rs | 52 +++++++ crates/ui-egui/tests/it/actions_replace.rs | 15 ++ crates/ui-egui/tests/it/atn_menu_item.rs | 145 +++++++++++++++++- 14 files changed, 359 insertions(+), 17 deletions(-) create mode 100644 crates/format/tests/it/read_limits.rs diff --git a/crates/format/src/read.rs b/crates/format/src/read.rs index b71e3c80305..8eea5bb0c0c 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -35,6 +35,12 @@ pub fn read_file_with(path: &Path, chunk: usize) -> io::Result> { read_path(path, chunk, MAX_OPEN_BYTES) } +/// Read a picked file within the caller's limit, before allocation and throughout any growth. +/// The global [`MAX_OPEN_BYTES`] ceiling and regular-file checks still apply. +pub fn read_file_limited(path: &Path, max: u64) -> io::Result> { + read_path(path, READ_CHUNK, max.min(MAX_OPEN_BYTES)) +} + /// [`read_file_with`] with a chosen total limit (tests use small ones). fn read_path(path: &Path, chunk: usize, max: u64) -> io::Result> { #[cfg(unix)] diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs index 71eae66dddc..19614c52e11 100644 --- a/crates/format/tests/it/main.rs +++ b/crates/format/tests/it/main.rs @@ -10,3 +10,5 @@ mod frame_stroke; mod layer_labels; mod roundtrip; mod smart_contents; + +mod read_limits; diff --git a/crates/format/tests/it/read_limits.rs b/crates/format/tests/it/read_limits.rs new file mode 100644 index 00000000000..305055fb44c --- /dev/null +++ b/crates/format/tests/it/read_limits.rs @@ -0,0 +1,17 @@ +//! Caller-specific file limits reuse the regular-file reader and preserve the default ceiling. + +/// Exact, over-limit, empty, and default reads use tiny regular files rather than large allocations. +#[test] +fn picked_read_limit_checks_before_allocating_and_preserves_default_reader() { + let dir = super::common::temp_dir("picked-read-limit"); + let path = dir.join("small.atn"); + std::fs::write(&path, b"12345").unwrap(); + assert_eq!(photocraft_format::read::read_file_limited(&path, 5).unwrap(), b"12345"); + assert_eq!(photocraft_format::read::read_file_limited(&path, 4).unwrap_err().kind(), std::io::ErrorKind::FileTooLarge); + assert_eq!(photocraft_format::read_file(&path).unwrap(), b"12345"); + std::fs::write(&path, b"").unwrap(); + assert!(photocraft_format::read::read_file_limited(&path, 0).unwrap().is_empty()); + assert!(photocraft_format::read::read_file_limited(&dir, 5).is_err()); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(dir).unwrap(); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e979d3d8298..ca47bb72b3c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -170,12 +170,16 @@ fn run_waiting(app: &mut PhotocraftApp, wait: bool, run: impl FnOnce(&mut Photoc const SHELL_DIALOG: &str = "shell"; pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + // Request identity, rather than an optional gate, determines programmatic playback. + let input = app.automation_input; + app.automation_input |= req.method == "engine.execute"; let saved = app.session.authorize; if let Some(gate) = app.services.automation_authorize { app.session.authorize = Some(gate); } let outcome = dispatch(app, ctx, req); app.session.authorize = saved; + app.automation_input = input; outcome } diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index bddc6796d3d..0cb7868d28e 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -231,11 +231,21 @@ impl PhotocraftApp { &mut self, extensions: &[&str], then: impl FnOnce(&mut Self, String, Vec) -> Result + 'static, + ) -> Result { + self.pick_file_bytes_with_limit(extensions, None, then) + } + + /// Pick a file with an optional caller limit; other pickers retain their existing read limit. + pub(crate) fn pick_file_bytes_with_limit( + &mut self, + extensions: &[&str], + max: Option, + then: impl FnOnce(&mut Self, String, Vec) -> Result + 'static, ) -> Result { let initial_dir = last_used_dir(&self.ui.recent_files); let extensions = (!extensions.is_empty()).then(|| extensions.iter().map(|s| (*s).to_string()).collect()); self.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions }, move |app, answer| { - let (name, bytes) = read_picked(answer)?; + let (name, bytes) = read_picked(answer, max)?; then(app, name, bytes) }) } @@ -377,18 +387,31 @@ pub(crate) fn last_used_dir(recent: &[String]) -> Option { } /// The name and bytes of the single file an open dialog answered with. -fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec), String> { - match answer { +fn read_picked(answer: FileDialogAnswer, max: Option) -> Result<(String, Vec), String> { + let picked: Result<(String, Vec), String> = match answer { FileDialogAnswer::Contents(name, bytes) => Ok((name, bytes)), FileDialogAnswer::ContentsMany(files) => files.into_iter().next().ok_or_else(|| CANCELLED.into()), FileDialogAnswer::Paths(paths) => { let path = paths.into_iter().next().ok_or(CANCELLED)?; - let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?; + let file = std::path::Path::new(&path); + let bytes = match max { + Some(max) => photocraft_format::read::read_file_limited(file, max), + None => photocraft_format::read_file(file), + } + .map_err(|e| format!("{}: {e}", display_name(&path)))?; Ok((path, bytes)) } FileDialogAnswer::FolderTo(_) => Err(UNEXPECTED.into()), FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => Err(UNEXPECTED.into()), + }; + let (name, bytes) = picked?; + // Web answers already own their platform-provided bytes: refuse before callbacks can parse or copy them. + if let Some(max) = max + && bytes.len() as u64 > max + { + return Err(format!("{}: the file is larger than {} bytes", display_name(&name), max)); } + Ok((name, bytes)) } /// A fake dialog service for tests: answers each request with the next of `answers` (`None`: diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 52149ab8e05..736fe41dad6 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1484,3 +1484,44 @@ fn import_variable_data_sets_menu_asks_for_a_csv_and_imports_it() { app.poll_file_dialog(&ctx, None); assert_eq!(app.session.active().map(|d| d.doc.variables.data_sets.len()), Some(2)); } + +/// A tiny caller cap rejects picked paths and existing byte payloads before their callback runs. +#[test] +fn picked_file_limit_refuses_before_callback_and_keeps_other_callers_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let exact = dir.path().join("exact.atn"); + let over = dir.path().join("over.atn"); + std::fs::write(&exact, b"1234").unwrap(); + std::fs::write(&over, b"12345").unwrap(); + for (chosen, max, allowed) in [ + (FileDialogAnswer::Paths(vec![exact.to_str().unwrap().into()]), Some(4), true), + (FileDialogAnswer::Paths(vec![over.to_str().unwrap().into()]), Some(4), false), + (FileDialogAnswer::Contents("exact.atn".into(), b"1234".to_vec()), Some(4), true), + (FileDialogAnswer::Contents("over.atn".into(), b"12345".to_vec()), Some(4), false), + (FileDialogAnswer::ContentsMany(vec![("over.atn".into(), b"12345".to_vec())]), Some(4), false), + (FileDialogAnswer::Paths(vec![over.to_str().unwrap().into()]), None, true), + (FileDialogAnswer::Contents("over.atn".into(), b"12345".to_vec()), None, true), + ] { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let called = Rc::new(std::cell::Cell::new(false)); + let callback = called.clone(); + let then = move |_: &mut PhotocraftApp, _: String, bytes: Vec| { + callback.set(true); + Ok(json!({"bytes":bytes.len()})) + }; + if let Some(max) = max { + app.pick_file_bytes_with_limit(&["atn"], Some(max), then).unwrap(); + } else { + app.pick_file_bytes_filtered(&["atn"], then).unwrap(); + } + app.poll_file_dialog(&ctx, None); + answer(&open, Some(chosen)); + app.poll_file_dialog(&ctx, None); + assert_eq!(called.get(), allowed); + if !allowed { + assert!(app.ui.status_error); + assert!(app.ui.status.contains("larger"), "{}", app.ui.status); + } + } +} diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 22883f8ba7b..acd105eb74a 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -272,7 +272,8 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } else { crate::dialogs::open_command_dialog(app, id, &label) }; - let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let document = app.session.active().map(|st| st.doc.id); + let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize, document); if let Some(paused) = app.paused_playbacks.paused.get_mut(&token) { paused.editor = Some((dialog, id.to_string())); } @@ -771,6 +772,8 @@ pub(crate) struct PausedPlayback { action: String, from: usize, gate: Option, + /// Stable document identity captured before this command's editor is shown. + document: Option, /// The real editor and fixed command; editable fields hold only its read-only resume token. editor: Option<(u64, String)>, /// The recording the paused call suspended: `{index, into, action, from}` (`into` is the @@ -797,9 +800,9 @@ pub(crate) struct PausedPlaybacks { } impl PausedPlaybacks { - fn pause(&mut self, action: String, from: usize, gate: Option) -> u64 { + fn pause(&mut self, action: String, from: usize, gate: Option, document: Option) -> u64 { self.last = self.last.wrapping_add(1); - self.paused.insert(self.last, PausedPlayback { action, from, gate, editor: None, record: None, then: Vec::new(), ran: 0 }); + self.paused.insert(self.last, PausedPlayback { action, from, gate, document, editor: None, record: None, then: Vec::new(), ran: 0 }); self.last } @@ -813,8 +816,19 @@ fn playback_editor(app: &PhotocraftApp, dialog: u64) -> Option<&PausedPlayback> app.paused_playbacks.paused.values().find(|p| p.editor.as_ref().is_some_and(|(id, _)| *id == dialog)) } +/// A paused editor may evaluate and resume only on the document that opened it. +fn validate_playback_document(app: &PhotocraftApp, owner: &PausedPlayback) -> Result<(), String> { + if app.session.active().map(|st| st.doc.id) != owner.document { + return Err("the paused action belongs to a different document".into()); + } + Ok(()) +} + /// The read-only resume token may only settle its own real command editor. pub(crate) fn validate_editor(app: &PhotocraftApp, dialog: u64, fields: &serde_json::Map) -> Result<(), String> { + if let Some(owner) = playback_editor(app, dialog) { + validate_playback_document(app, owner)?; + } let Some(token) = fields.get(RESUME) else { return Ok(()) }; let owner = token .as_u64() @@ -834,6 +848,7 @@ pub(crate) fn validate_editor(app: &PhotocraftApp, dialog: u64, fields: &serde_j pub(crate) fn authorize_preview(app: &PhotocraftApp, dialog: u64, command: &str, params: &Value) -> Result<(), String> { record_again::authorize_preview(app, dialog, command, params)?; if let Some(owner) = playback_editor(app, dialog) { + validate_playback_document(app, owner)?; if owner.editor.as_ref().is_none_or(|(_, id)| id != command) { return Err("the paused action preview command changed".into()); } @@ -904,6 +919,8 @@ pub(crate) fn confirm_owned_dialog(app: &mut PhotocraftApp, dialog: u64, fields: /// confirmed: a control request, synthetic Enter or OK, or a click. pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { let Some(token) = fields.get(RESUME) else { return Ok(()) }; + let owner = token.as_u64().and_then(|t| app.paused_playbacks.paused.get(&t)).ok_or("this dialog doesn't belong to a paused action")?; + validate_playback_document(app, owner)?; let Some(paused) = app.paused_playbacks.take(token) else { return Err("this dialog doesn't belong to a paused action".into()); }; diff --git a/crates/ui-egui/src/panels/actions/menu_item.rs b/crates/ui-egui/src/panels/actions/menu_item.rs index 666f6e101fd..d0388ee4373 100644 --- a/crates/ui-egui/src/panels/actions/menu_item.rs +++ b/crates/ui-egui/src/panels/actions/menu_item.rs @@ -27,9 +27,6 @@ pub(super) fn open(app: &mut PhotocraftApp) -> Result { let target = json!({"action":action,"at":app.ui.actions.selected_step.map(|i|i.saturating_add(1))}); let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); let automated = app.automation_input || gate.is_some(); - let mut params = target.clone(); - params["id"] = json!("image.imageSize"); - super::authorize_owned(app, COMMAND, ¶ms, gate, automated)?; let fields = json!({MARK:true,"__command":COMMAND,"__label":"Insert Menu Item","id":"image.imageSize"}); let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); app.held_insert_menu = Some(Held { dialog, revision: app.session.actions.rev, target, gate, automated }); diff --git a/crates/ui-egui/src/panels/actions/replace.rs b/crates/ui-egui/src/panels/actions/replace.rs index c79f12df3cd..da16d1054ac 100644 --- a/crates/ui-egui/src/panels/actions/replace.rs +++ b/crates/ui-egui/src/panels/actions/replace.rs @@ -46,14 +46,11 @@ pub(super) fn open(app: &mut PhotocraftApp) -> Result { let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); let automated = app.automation_input || gate.is_some(); authorize(app, &json!({"confirm": true}), gate, automated)?; - app.pick_file_bytes_filtered(&["atn"], move |app, name, bytes| { + app.pick_file_bytes_with_limit(&["atn"], Some(photocraft_engine::preset_import_cmds::MAX_FILE_BYTES), move |app, name, bytes| { idle(app)?; if app.session.actions.rev != revision || app.held_actions_replace.is_some() { return Err(tl!("The actions changed. Request Replace Actions again.").into()); } - if bytes.len() as u64 > photocraft_engine::preset_import_cmds::MAX_FILE_BYTES { - return Err(tl!("The Actions file is too large.").into()); - } // Validate before asking to discard the library. The normal command parses this // same immutable input again on confirmation, keeping dispatch authoritative. let count = photocraft_engine::atn_cmds::import(&bytes)?.actions.len(); @@ -87,11 +84,13 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map Result { } let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); let automated = app.automation_input || gate.is_some(); - super::authorize_owned(app, COMMAND, &json!({"mode":"append","confirm":true}), gate, automated)?; - let fields = json!({MARK:true,"__command":COMMAND,"__label":"Reset Actions","mode":"append"}); + let mode = if super::authorize_owned(app, COMMAND, &json!({"mode":"append","confirm":true}), gate, automated).is_ok() { + "append" + } else { + super::authorize_owned(app, COMMAND, &json!({"mode":"replace","confirm":true}), gate, automated)?; + "replace" + }; + let fields = json!({MARK:true,"__command":COMMAND,"__label":"Reset Actions","mode":mode}); let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); app.held_actions_reset = Some(Held { dialog, revision: app.session.actions.rev, gate, automated }); Ok(json!({"dialog":dialog})) diff --git a/crates/ui-egui/tests/it/actions_clear_all.rs b/crates/ui-egui/tests/it/actions_clear_all.rs index 316b9ebe24e..ed5c5c1f290 100644 --- a/crates/ui-egui/tests/it/actions_clear_all.rs +++ b/crates/ui-egui/tests/it/actions_clear_all.rs @@ -120,3 +120,24 @@ fn stale_denied_or_retargeted_confirmations_never_clear_the_library() { assert_eq!(h.state().session.active().unwrap().revision, revision, "{mode}"); } } + +/// Suspended recording, rather than a stale warning revision, prevents Clear All from losing a call. +#[test] +fn clear_confirmation_refuses_a_recording_suspended_at_a_step_dialog() { + let mut h = harness(); + h.state_mut().background_jobs = false; + let a = &mut h.state_mut().session.actions.list[0]; + a.steps = vec![("filter.blur.gaussianBlur".into(), json!({"radius":1}))]; + a.dialogs.insert(0); + let warning = open(&mut h); + let revision = h.state().session.actions.rev; + h.state_mut().run("actions.record", json!({"action":"B"})).unwrap(); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + assert!(paused["paused"]["dialog"].is_u64()); + assert!(h.state().session.actions.recording.is_none()); + assert_eq!(h.state().session.actions.rev, revision, "the original warning is still current"); + let library = h.state().session.actions.list.clone(); + let error = dialogs::confirm(h.state_mut(), warning).unwrap_err(); + assert!(error.contains("step dialog"), "{error}"); + assert_eq!(h.state().session.actions.list, library); +} diff --git a/crates/ui-egui/tests/it/actions_panel_menu.rs b/crates/ui-egui/tests/it/actions_panel_menu.rs index a29feb358ff..9b7da8d6744 100644 --- a/crates/ui-egui/tests/it/actions_panel_menu.rs +++ b/crates/ui-egui/tests/it/actions_panel_menu.rs @@ -92,6 +92,20 @@ fn insert_menu_item_chooser_cancels_or_records_descriptor_free_filter_event() { let dialog = played["paused"]["dialog"].as_u64().unwrap(); assert_eq!(h.state_mut().ui.dialog_mut(dialog).unwrap().fields["__command"], "filter.blur.gaussianBlur"); dialogs::cancel(h.state_mut(), dialog).unwrap(); + h.state_mut().run("paint.stroke", json!({"points":[[6,6]],"size":3,"hardness":1,"color":"#000000"})).unwrap(); + let original = photocraft_compose::flatten(&h.state().session.active().unwrap().doc).into_px(); + let mut expected = Session::new(); + expected.add_document((*h.state().session.active().unwrap().doc).clone(), None); + expected.execute("filter.blur.gaussianBlur", json!({"radius":2})).unwrap(); + let expected = photocraft_compose::flatten(&expected.active().unwrap().doc).into_px(); + assert_ne!(expected, original, "the Gaussian fixture has a visible effect"); + h.state_mut().session.actions.list[0].steps.push(("select.all".into(), json!({}))); + let paused = h.state_mut().run("actions.play", json!({"action":"A"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!(2)); + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert_eq!(photocraft_compose::flatten(&h.state().session.active().unwrap().doc).into_px(), expected); + assert!(h.state().session.active().unwrap().doc.selection.is_some(), "confirmation resumes the following step"); } /// The selected path is snapshotted without applying it to the document. @@ -215,3 +229,41 @@ fn paused_menu_previews_and_picker_callbacks_keep_the_requesting_gate() { assert_eq!(h.state().session.active().unwrap().revision, revision); assert_eq!(h.state().session.actions.list[0].steps[0].1, json!({})); } + +/// Policy may permit the chosen filter while refusing Image Size and append mode. +fn chosen_only(id: &str, p: &Value) -> photocraft_engine::Result<()> { + if id == "image.imageSize" + || id == "actions.insertMenuItem" && p.get("id").is_some_and(|v| v == "image.imageSize") + || id == "actions.reset" && p.get("mode").is_some_and(|v| v == "append") + { + Err(EngineError::Other("choice denied".into())) + } else { + Ok(()) + } +} + +/// The chooser is not pre-authorized as the default command the user might never select. +#[test] +fn menu_chooser_authorizes_the_selected_command_without_default_probe() { + let mut h = harness(); + h.state_mut().session.authorize = Some(chosen_only); + let dialog = open(&mut h, "Insert Menu Item…"); + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("id".into(), json!("filter.blur.gaussianBlur")); + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert_eq!(h.state().session.actions.list[0].steps.len(), 1); + let dialog = open(&mut h, "Insert Menu Item…"); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list[0].steps.len(), 1); +} + +/// Replace must remain reachable when the policy refuses Reset's append option. +#[test] +fn reset_offers_allowed_replace_when_append_is_denied() { + let mut h = harness(); + h.state_mut().session.authorize = Some(chosen_only); + let dialog = open(&mut h, "Reset Actions…"); + assert_eq!(h.state_mut().ui.dialog_mut(dialog).unwrap().fields["mode"], "replace"); + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert!(h.state().session.actions.list.is_empty()); + assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); +} diff --git a/crates/ui-egui/tests/it/actions_replace.rs b/crates/ui-egui/tests/it/actions_replace.rs index 38b681c65d6..acde6baeb0c 100644 --- a/crates/ui-egui/tests/it/actions_replace.rs +++ b/crates/ui-egui/tests/it/actions_replace.rs @@ -177,3 +177,18 @@ fn another_dialog_cannot_consume_the_pending_replacement() { assert_eq!(h.state().session.actions.list[0].name, "New"); assert_eq!(h.state().session.actions.list.len(), 1); } + +/// Replacing a library removes its collapsed set names even when the replacement reuses one. +#[test] +fn replacing_library_reveals_reused_set_names() { + let (mut h, picker) = harness(); + h.state_mut().ui.actions.collapsed_sets = vec!["One".into(), "Two".into(), "Old Empty".into(), "Remember".into()]; + pick(&mut h, &picker); + let action = Action::new("New", vec![("select.all".into(), json!({}))]); + let bytes = atn_cmds::export("One", &[&action], &[], false).unwrap().0; + answer(&mut h, &picker, Some(FileDialogAnswer::Contents("Replacement.atn".into(), bytes))); + let dialog = h.state().ui.dialogs.last().unwrap().id; + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert_eq!(h.state().session.actions.list[0].set.as_deref(), Some("One")); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Remember"]); +} diff --git a/crates/ui-egui/tests/it/atn_menu_item.rs b/crates/ui-egui/tests/it/atn_menu_item.rs index d87c92323d9..e811e64efb1 100644 --- a/crates/ui-egui/tests/it/atn_menu_item.rs +++ b/crates/ui-egui/tests/it/atn_menu_item.rs @@ -1,7 +1,7 @@ //! Play a descriptor-free Image Size through its actual dialog and Actions continuation. use egui_kittest::{Harness, kittest::Queryable}; use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds}; -use photocraft_ui_egui::{PhotocraftApp, dialogs}; +use photocraft_ui_egui::{PhotocraftApp, actions, canvas, control, dialogs}; use serde_json::{Value, json}; /// Import a menu step and append a selection step to expose continuation ordering. @@ -93,3 +93,146 @@ fn menu_item_confirmation_keeps_the_playback_gate_after_the_request_ends() { assert_eq!(app.session.active().unwrap().revision, revision); assert!(app.session.active().unwrap().doc.selection.is_none()); } + +/// A retained policy does not itself make native playback programmatic. +fn permit(_: &str, _: &Value) -> photocraft_engine::Result<()> { + Ok(()) +} + +/// Real engine.execute requests refuse unconfigured menu intent even without an installed gate. +#[test] +fn control_menu_intent_requires_interaction_with_default_and_gated_services() { + for gated in [false, true] { + let mut app = app(); + app.background_jobs = false; + if gated { + app.services.automation_authorize = Some(permit); + } + let revision = app.session.active().unwrap().revision; + let ctx = egui::Context::default(); + let (request, _) = control::ControlRequest::new("engine.execute", json!({"command":"actions.play","params":{"action":"Menu"}})); + let control::Outcome::Done(response) = control::handle(&mut app, &ctx, &request) else { panic!("immediate action response") }; + assert_eq!(response["ok"], true, "{response}"); + let result = &response["result"]; + assert!(result["failed"]["error"].as_str().is_some_and(|e| e.contains("interactive-required")), "{response}"); + assert!(result.get("paused").is_none()); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(app.session.active().unwrap().revision, revision); + app.session.authorize = Some(permit); + let paused = app.run("actions.play", json!({"action":"Menu"})).unwrap(); + dialogs::cancel(&mut app, paused["paused"]["dialog"].as_u64().unwrap()).unwrap(); + } +} + +/// Recorded settings remain executable by the actual headless request path. +#[test] +fn control_recorded_settings_ignore_the_native_dialog_toggle() { + let mut app = app(); + app.background_jobs = false; + app.session.actions.list[0].steps[0] = ("image.imageSize".into(), json!({"width":4,"height":3,"resample":"nearest"})); + app.session.actions.list[0].dialogs.insert(0); + let (request, _) = control::ControlRequest::new("engine.execute", json!({"command":"actions.play","params":{"action":"Menu"}})); + let control::Outcome::Done(result) = control::handle(&mut app, &egui::Context::default(), &request) else { panic!("immediate action response") }; + assert_eq!(result["result"]["ran"], 2, "{result}"); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(4, 3)); +} + +/// Window Arrange's real cell activation must not retarget a paused Image Size confirmation. +#[test] +fn arranged_document_switch_cannot_retarget_paused_menu_confirmation() { + let mut app = app(); + app.background_jobs = false; + app.run("file.new", json!({"width":20,"height":10})).unwrap(); + app.session.set_active(0); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = photocraft_ui_egui::state::Tool::Hand; + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + // The harness draws once before setup_context installs the app's named fonts. + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + canvas::document_area(app, ui); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + let paused = h.state_mut().run("actions.play", json!({"action":"Menu"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields; + fields.insert("width".into(), json!(4)); + fields.insert("height".into(), json!(3)); + let before: Vec<_> = h.state().session.documents().iter().map(|s| (s.doc.id, s.doc.size, s.revision)).collect(); + let pos = egui::pos2(1050.0, 850.0); + h.event(egui::Event::PointerMoved(pos)); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().session.active_index(), Some(1), "the real arranged-cell producer activated the other document"); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.documents().iter().map(|s| (s.doc.id, s.doc.size, s.revision)).collect::>(), before); + assert!(h.state().session.documents().iter().all(|s| s.doc.selection.is_none())); + h.state_mut().session.set_active(0); + dialogs::confirm(h.state_mut(), dialog).unwrap(); + assert_eq!(h.state().session.documents()[0].doc.size, photocraft_doc::Size::new(4, 3)); + assert!(h.state().session.documents()[0].doc.selection.is_some()); +} + +/// A stale resume cannot run its following selection on another document; later intentional New remains valid. +#[test] +fn paused_resume_checks_owner_before_later_document_changing_steps() { + let mut app = app(); + app.background_jobs = false; + app.run("file.new", json!({"width":20,"height":10})).unwrap(); + app.session.set_active(0); + app.session.actions.list[0].steps.insert(1, ("file.new".into(), json!({"width":3,"height":2}))); + let paused = app.run("actions.play", json!({"action":"Menu"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let fields = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + app.session.set_active(1); + assert!(actions::resume(&mut app, &fields).is_err()); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.active().unwrap().doc.selection.is_none()); + app.session.set_active(0); + dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(app.session.documents().len(), 3); + assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(3, 2)); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} + +static PREVIEW_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +/// Count actual preview evaluations while retaining permission for the originating document. +fn count_preview(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "filter.blur.gaussianBlur" { + PREVIEW_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + Ok(()) +} + +/// A paused filter preview cannot evaluate its parameters on a different active document. +#[test] +fn paused_filter_preview_stays_with_its_document() { + use std::sync::atomic::Ordering; + let mut app = app(); + app.background_jobs = false; + app.run("file.new", json!({"width":20,"height":10})).unwrap(); + app.session.set_active(0); + app.session.actions.list[0].steps[0].1["id"] = json!("filter.blur.gaussianBlur"); + app.session.authorize = Some(count_preview); + let paused = app.run("actions.play", json!({"action":"Menu"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + PREVIEW_CALLS.store(0, Ordering::SeqCst); + assert!(canvas::ensure_texture(&mut app, &ctx, 0, None).is_some()); + assert!(PREVIEW_CALLS.load(Ordering::SeqCst) > 0, "same-owner preview evaluated"); + app.session.set_active(1); + PREVIEW_CALLS.store(0, Ordering::SeqCst); + canvas::ensure_texture(&mut app, &ctx, 1, None); + assert_eq!(PREVIEW_CALLS.load(Ordering::SeqCst), 0, "foreign document must not evaluate the paused filter"); + assert!(dialogs::confirm(&mut app, dialog).is_err()); +} From f81c02851492fce1d2c55aef4a3c7ef7c558f108 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 12:19:52 -0800 Subject: [PATCH 066/384] fix: bound browser picker reads and gate Reset modes --- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/main.rs | 3 + apps/photocraft-web/src/picked_file.rs | 56 +++++++++++++++++++ apps/photocraft-web/src/web.rs | 36 +++++++----- apps/photocraft/src/services.rs | 2 +- crates/format/src/read.rs | 12 ++++ crates/format/tests/it/read_limits.rs | 2 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/filter_dialog.rs | 2 +- crates/ui-egui/src/dialogs/stack_dialog.rs | 2 +- crates/ui-egui/src/file_dialog.rs | 12 +++- crates/ui-egui/src/file_dialog/tests.rs | 8 ++- crates/ui-egui/src/panels/actions.rs | 2 +- crates/ui-egui/src/panels/actions/replace.rs | 4 +- crates/ui-egui/src/panels/actions/reset.rs | 28 ++++++---- crates/ui-egui/src/smart_svg_ui.rs | 1 + crates/ui-egui/tests/it/actions_panel_menu.rs | 51 +++++++++++++++++ crates/ui-egui/tests/it/actions_replace.rs | 8 ++- 18 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 apps/photocraft-web/src/picked_file.rs diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 0176e0fe84c..9458afacf33 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -30,7 +30,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] } -rfd = { workspace = true } +rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 0802a02ebfc..75ff86cb8a5 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -19,6 +19,9 @@ #[cfg(any(target_arch = "wasm32", test))] mod preset_bridge; +#[cfg(any(target_arch = "wasm32", test))] +mod picked_file; + #[cfg(target_arch = "wasm32")] mod indexed_presets; diff --git a/apps/photocraft-web/src/picked_file.rs b/apps/photocraft-web/src/picked_file.rs new file mode 100644 index 00000000000..ad183cbc6c5 --- /dev/null +++ b/apps/photocraft-web/src/picked_file.rs @@ -0,0 +1,56 @@ +//! Admit a browser file from its immutable size before invoking the whole-file reader. +use std::future::Future; + +/// Keep the size check before creating the read future, shared by single and multiple selection. +pub async fn read(name: String, size: f64, max: Option, read: F) -> Result<(String, Vec), String> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if let Some(max) = max + && (!size.is_finite() || size < 0.0 || size > max as f64) + { + return Err(format!("{name}: the file is larger than {max} bytes or its size is invalid")); + } + Ok((name, read().await)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::task::{Context, Poll, Waker}; + + /// Tiny metadata-only cases prove oversize refusal does not even invoke the payload reader. + #[test] + fn size_limit_precedes_the_browser_read_and_preserves_uncapped_reads() { + for (size, max, allowed) in [ + (4.0, Some(4), true), + (5.0, Some(4), false), + (0.0, Some(0), true), + (f64::NAN, Some(4), false), + (f64::INFINITY, Some(4), false), + (-1.0, Some(4), false), + (5.0, None, true), + ] { + let called = Cell::new(false); + let bytes = if size == 0.0 { Vec::new() } else { vec![1, 2, 3, 4] }; + let future = read("picked.atn".into(), size, max, || { + assert!(allowed, "an oversized file must not be read"); + called.set(true); + std::future::ready(bytes.clone()) + }); + let mut future = std::pin::pin!(future); + let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) else { panic!("an immediate read") }; + assert_eq!(called.get(), allowed); + assert_eq!(result.is_ok(), allowed); + if allowed { + let (name, actual) = result.unwrap(); + assert_eq!(name, "picked.atn"); + assert_eq!(actual, bytes); + } else { + assert!(result.unwrap_err().contains("picked.atn")); + } + } + } +} diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 024888c65d1..c320b67f1a7 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -419,28 +419,36 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu file_dialog: Some(Box::new(|request, _parent, reply| match request { // The browser's file picker hands over the files' contents, not paths. File › Open // asks for several files (`multiple`), each opened in its own tab. - FileDialogRequest::Open { multiple, extensions, .. } => wasm_bindgen_futures::spawn_local(async move { + FileDialogRequest::Open { multiple, extensions, max_bytes, .. } => wasm_bindgen_futures::spawn_local(async move { let dialog = if let Some(exts) = extensions { rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts) } else { rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS) }; - let answer = if multiple { - match dialog.pick_files().await { - Some(picked) if !picked.is_empty() => { - let mut files = Vec::with_capacity(picked.len()); - for file in picked { - files.push((file.file_name(), file.read().await)); + let picked = if multiple { dialog.pick_files().await } else { dialog.pick_file().await.map(|file| vec![file]) }; + let answer = match picked { + Some(picked) if !picked.is_empty() => { + let mut files = Vec::with_capacity(picked.len()); + let mut error = None; + for file in picked { + // File/Blob size is immutable; reject before rfd creates its ArrayBuffer/Vec. + match crate::picked_file::read(file.file_name(), file.inner().size(), max_bytes, || file.read()).await { + Ok(file) => files.push(file), + Err(why) => { + error = Some(why); + break; + } } - Some(FileDialogAnswer::ContentsMany(files)) } - _ => None, - } - } else { - match dialog.pick_file().await { - Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)), - None => None, + if let Some(why) = error { + Some(FileDialogAnswer::Unavailable(why)) + } else if multiple { + Some(FileDialogAnswer::ContentsMany(files)) + } else { + files.pop().map(|(name, bytes)| FileDialogAnswer::Contents(name, bytes)) + } } + _ => None, }; reply.send(answer); }), diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 675996c5d0d..96a5272524d 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -207,7 +207,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, dialog = dialog.set_parent(parent); } let answer: Pin> + Send>> = match request { - FileDialogRequest::Open { multiple, initial_dir, extensions } => { + FileDialogRequest::Open { multiple, initial_dir, extensions, .. } => { if let Some(dir) = initial_dir { dialog = dialog.set_directory(dir); } diff --git a/crates/format/src/read.rs b/crates/format/src/read.rs index 8eea5bb0c0c..cafdfb8176d 100644 --- a/crates/format/src/read.rs +++ b/crates/format/src/read.rs @@ -231,4 +231,16 @@ mod tests { assert_eq!(size_text(4_750_000_000), "4.75 GB"); assert_eq!(size_text(759_000_000), "759.00 MB"); } + + /// An over-limit metadata hint must refuse before touching the reader. + #[test] + fn a_size_hint_over_the_limit_fails_before_any_read() { + struct NeverRead; + impl Read for NeverRead { + fn read(&mut self, _: &mut [u8]) -> io::Result { + panic!("the size hint must be checked before reading"); + } + } + assert_eq!(read_all_with(&mut NeverRead, 5, 64, 4).unwrap_err().kind(), io::ErrorKind::FileTooLarge); + } } diff --git a/crates/format/tests/it/read_limits.rs b/crates/format/tests/it/read_limits.rs index 305055fb44c..d17b32e20e1 100644 --- a/crates/format/tests/it/read_limits.rs +++ b/crates/format/tests/it/read_limits.rs @@ -2,7 +2,7 @@ /// Exact, over-limit, empty, and default reads use tiny regular files rather than large allocations. #[test] -fn picked_read_limit_checks_before_allocating_and_preserves_default_reader() { +fn picked_read_limit_preserves_exact_default_and_regular_file_behavior() { let dir = super::common::temp_dir("picked-read-limit"); let path = dir.join("small.atn"); std::fs::write(&path, b"12345").unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e7351ef4765..83ff29f1059 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,7 +26,7 @@ fn browse_field(app: &mut PhotocraftApp, id: u64, fields: &serde_json::Map fill_field(app, id, &key, paths.remove(0)), _ => Err("Install Plug-in reads a .wasm file from disk".into()), diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index e810d0086d5..d052e5f3a5a 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -1099,7 +1099,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, dialog: u64, command: St use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; let doc = app.active_doc_id()?; let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); - app.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions: None }, move |app, answer| { + app.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions: None, max_bytes: None }, move |app, answer| { let path = match answer { FileDialogAnswer::Paths(paths) => paths.into_iter().next().ok_or("no displacement map was chosen")?, FileDialogAnswer::Unavailable(e) => return Err(e), diff --git a/crates/ui-egui/src/dialogs/stack_dialog.rs b/crates/ui-egui/src/dialogs/stack_dialog.rs index e78563b83e7..cdbd8006336 100644 --- a/crates/ui-egui/src/dialogs/stack_dialog.rs +++ b/crates/ui-egui/src/dialogs/stack_dialog.rs @@ -98,7 +98,7 @@ fn browse(app: &mut PhotocraftApp, id: u64, folder: bool) { }) } else { let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); - app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, move |app, answer| { + app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None, max_bytes: None }, move |app, answer| { let FileDialogAnswer::Paths(paths) = answer else { return Err("Load Files into Stack reads files from disk".into()) }; let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; add(&mut d.fields, paths); diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 0cb7868d28e..e93686642c6 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -26,7 +26,13 @@ use crate::file_open::display_name; pub enum FileDialogRequest { /// Files to read: several for File › Open, one for a command that reads a file (Place, /// scripts, notes, presets). Starts in `initial_dir` (the last-used folder, UI-217-3). - Open { multiple: bool, initial_dir: Option, extensions: Option> }, + Open { + multiple: bool, + initial_dir: Option, + extensions: Option>, + /// Per-file limit: byte-producing services must check size before reading; consumers recheck. + max_bytes: Option, + }, /// Which folder to read from. Starts in `initial_dir` when available. Folder { initial_dir: Option }, /// Where to write, starting from `suggested` (a file name, or the document's own path). @@ -244,7 +250,7 @@ impl PhotocraftApp { ) -> Result { let initial_dir = last_used_dir(&self.ui.recent_files); let extensions = (!extensions.is_empty()).then(|| extensions.iter().map(|s| (*s).to_string()).collect()); - self.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions }, move |app, answer| { + self.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions, max_bytes: max }, move |app, answer| { let (name, bytes) = read_picked(answer, max)?; then(app, name, bytes) }) @@ -253,7 +259,7 @@ impl PhotocraftApp { /// File › Open: opens every chosen file, reporting each failure (see [`Self::open_paths`]). pub fn open_dialog_file(&mut self) -> Result { let initial_dir = last_used_dir(&self.ui.recent_files); - self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None }, |app, answer| { + self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None, max_bytes: None }, |app, answer| { match answer { FileDialogAnswer::Paths(paths) => { app.open_paths(&paths); diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 736fe41dad6..418f899ac7c 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -378,7 +378,7 @@ fn open_starts_in_the_last_used_folder() { menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap(); app.poll_file_dialog(&ctx, None); assert!( - matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: true, initial_dir: Some(dir), extensions: None }, _)] if dir == "/pics"), + matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: true, initial_dir: Some(dir), extensions: None, max_bytes: None }, _)] if dir == "/pics"), "{:?}", open.borrow().first().map(|(r, _)| r.clone()) ); @@ -1516,12 +1516,18 @@ fn picked_file_limit_refuses_before_callback_and_keeps_other_callers_unchanged() app.pick_file_bytes_filtered(&["atn"], then).unwrap(); } app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { max_bytes, .. }, _)] if *max_bytes == max)); + let bounded_path = max.is_some() && matches!(chosen, FileDialogAnswer::Paths(_)); answer(&open, Some(chosen)); app.poll_file_dialog(&ctx, None); assert_eq!(called.get(), allowed); if !allowed { assert!(app.ui.status_error); assert!(app.ui.status.contains("larger"), "{}", app.ui.status); + if bounded_path { + let limited = photocraft_format::read::read_file_limited(&over, 4).unwrap_err().to_string(); + assert!(app.ui.status.contains(&limited), "{}", app.ui.status); + } } } } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index acd105eb74a..f815a0f0c4d 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -887,7 +887,7 @@ pub(crate) fn owned_dialog_body(app: &PhotocraftApp, ui: &mut egui::Ui, dialog: } else if replace::owns(fields) { replace::body(app, ui, dialog); } else if reset::owns(fields) { - reset::body(ui, fields); + reset::body(app, ui, dialog, fields); } else if menu_item::owns(fields) { menu_item::body(ui, fields); } else { diff --git a/crates/ui-egui/src/panels/actions/replace.rs b/crates/ui-egui/src/panels/actions/replace.rs index da16d1054ac..23dd31a0622 100644 --- a/crates/ui-egui/src/panels/actions/replace.rs +++ b/crates/ui-egui/src/panels/actions/replace.rs @@ -54,7 +54,9 @@ pub(super) fn open(app: &mut PhotocraftApp) -> Result { // Validate before asking to discard the library. The normal command parses this // same immutable input again on confirmation, keeping dispatch authoritative. let count = photocraft_engine::atn_cmds::import(&bytes)?.actions.len(); - let params = json!({"data": photocraft_engine::paint::tile::b64_encode(&bytes), "confirm": true}); + let mut params = json!({"confirm": true}); + // Move the encoded allocation into the transport rather than serializing another String copy. + params["data"] = Value::String(photocraft_engine::paint::tile::b64_encode(&bytes)); drop(bytes); authorize(app, ¶ms, gate, automated)?; let fields = json!({MARK: true, "__command": COMMAND, "__label": "Replace Actions"}); diff --git a/crates/ui-egui/src/panels/actions/reset.rs b/crates/ui-egui/src/panels/actions/reset.rs index 81924b492ed..1b3148eecd5 100644 --- a/crates/ui-egui/src/panels/actions/reset.rs +++ b/crates/ui-egui/src/panels/actions/reset.rs @@ -10,6 +10,8 @@ pub(crate) struct Held { revision: u64, gate: Option, automated: bool, + append: bool, + replace: bool, } /// Identify the fixed reset workflow through the protected route. @@ -34,24 +36,26 @@ pub(super) fn open(app: &mut PhotocraftApp) -> Result { } let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); let automated = app.automation_input || gate.is_some(); - let mode = if super::authorize_owned(app, COMMAND, &json!({"mode":"append","confirm":true}), gate, automated).is_ok() { - "append" - } else { - super::authorize_owned(app, COMMAND, &json!({"mode":"replace","confirm":true}), gate, automated)?; - "replace" - }; + let append = super::authorize_owned(app, COMMAND, &json!({"mode":"append","confirm":true}), gate, automated).is_ok(); + let replace = super::authorize_owned(app, COMMAND, &json!({"mode":"replace","confirm":true}), gate, automated); + let allowed_replace = replace.is_ok(); + if !append { + replace?; + } + let mode = if append { "append" } else { "replace" }; let fields = json!({MARK:true,"__command":COMMAND,"__label":"Reset Actions","mode":mode}); let dialog = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); - app.held_actions_reset = Some(Held { dialog, revision: app.session.actions.rev, gate, automated }); + app.held_actions_reset = Some(Held { dialog, revision: app.session.actions.rev, gate, automated, append, replace: allowed_replace }); Ok(json!({"dialog":dialog})) } /// Clearly label the temporary content and offer append, replace or the standard Cancel. -pub(crate) fn body(ui: &mut egui::Ui, fields: &mut Map) { +pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, dialog: u64, fields: &mut Map) { + let held = app.held_actions_reset.as_ref().filter(|held| held.dialog == dialog); ui.label(tl!("Default Actions contents are not available yet. This restores an empty placeholder set.")); let mut mode = fields.get("mode").and_then(Value::as_str).unwrap_or("append").to_string(); - ui.radio_value(&mut mode, "append".into(), tl!("Append")); - ui.radio_value(&mut mode, "replace".into(), tl!("Replace")); + ui.add_enabled_ui(held.is_some_and(|held| held.append), |ui| ui.radio_value(&mut mode, "append".into(), tl!("Append"))); + ui.add_enabled_ui(held.is_some_and(|held| held.replace), |ui| ui.radio_value(&mut mode, "replace".into(), tl!("Replace"))); if mode == "replace" { ui.label(tl!("This removes the current action library and cannot be undone.")); } @@ -70,6 +74,7 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, dialog: u64, fields: &Map Result { multiple: false, initial_dir: None, extensions: if cfg!(windows) { Some(vec!["exe".into()]) } else { None }, + max_bytes: None, }, move |app, answer| { let crate::file_dialog::FileDialogAnswer::Paths(paths) = answer else { return Err("Choose a local editor executable".into()) }; diff --git a/crates/ui-egui/tests/it/actions_panel_menu.rs b/crates/ui-egui/tests/it/actions_panel_menu.rs index 9b7da8d6744..d3d3479fcf6 100644 --- a/crates/ui-egui/tests/it/actions_panel_menu.rs +++ b/crates/ui-egui/tests/it/actions_panel_menu.rs @@ -267,3 +267,54 @@ fn reset_offers_allowed_replace_when_append_is_denied() { assert!(h.state().session.actions.list.is_empty()); assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); } + +/// Only this test changes the policy; the same gate identity can revoke consent after opening. +static RESET_MODES: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(3); +/// The policy is queried independently for each mode, including again at confirmation. +fn reset_modes(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "actions.reset" { + let bit = if params["mode"] == "append" { 1 } else { 2 }; + if RESET_MODES.load(Ordering::SeqCst) & bit == 0 { + return Err(EngineError::Other("reset mode denied".into())); + } + } + Ok(()) +} + +/// Denied radios stay disabled; replace forgets old collapsed sets, append keeps them, and gates recheck. +#[test] +fn reset_modes_and_replaced_set_disclosures_follow_retained_and_current_policy() { + use egui_kittest::kittest::NodeT; + for allowed in [0, 1, 2, 3] { + RESET_MODES.store(allowed, Ordering::SeqCst); + let mut h = harness(); + h.state_mut().session.authorize = Some(reset_modes); + h.state_mut().ui.actions.collapsed_sets = vec!["S".into(), "Remember".into()]; + let before = h.state().session.actions.list.clone(); + if allowed == 0 { + h.get_by_label("Reset Actions…").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.is_empty()); + assert_eq!(h.state().session.actions.list, before); + continue; + } + let dialog = open(&mut h, "Reset Actions…"); + assert_eq!(h.get_by_label("Append").accesskit_node().is_disabled(), allowed & 1 == 0); + assert_eq!(h.get_by_label("Replace").accesskit_node().is_disabled(), allowed & 2 == 0); + if allowed == 3 { + RESET_MODES.store(0, Ordering::SeqCst); + assert!(dialogs::confirm(h.state_mut(), dialog).is_err()); + assert_eq!(h.state().session.actions.list, before); + assert_eq!(h.state().ui.actions.collapsed_sets, ["S", "Remember"]); + } else { + dialogs::confirm(h.state_mut(), dialog).unwrap(); + if allowed == 1 { + assert_eq!(h.state().session.actions.list, before); + assert_eq!(h.state().ui.actions.collapsed_sets, ["S", "Remember"]); + } else { + assert!(h.state().session.actions.list.is_empty()); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Remember"]); + } + } + } +} diff --git a/crates/ui-egui/tests/it/actions_replace.rs b/crates/ui-egui/tests/it/actions_replace.rs index acde6baeb0c..0d08f0fd2fd 100644 --- a/crates/ui-egui/tests/it/actions_replace.rs +++ b/crates/ui-egui/tests/it/actions_replace.rs @@ -55,7 +55,8 @@ fn input() -> Vec { fn pick(h: &mut Harness<'static, PhotocraftApp>, picker: &Picker) { h.get_by_role_and_label(egui::accesskit::Role::Button, "Replace Actions…").click(); h.run_steps(3); - assert!(matches!(picker.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(ext), .. }, _)] if ext == &["atn"])); + assert!(matches!(picker.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(ext), max_bytes: Some(max), .. }, _)] + if ext == &["atn"] && *max == photocraft_engine::preset_import_cmds::MAX_FILE_BYTES)); } /// Deliver a platform answer through the real continuation, without bypassing staging. @@ -87,6 +88,11 @@ fn cancelling_preserves_actions_and_confirmation_never_rereads_the_source_path() pick(&mut h, &picker); answer(&mut h, &picker, Some(FileDialogAnswer::Contents("Broken.atn".into(), b"bad".to_vec()))); assert!(h.state().ui.dialogs.is_empty()); + pick(&mut h, &picker); + answer(&mut h, &picker, Some(FileDialogAnswer::Unavailable("Picked.atn: the file is larger than the picker limit".into()))); + assert!(h.state().ui.dialogs.is_empty()); + assert!(h.state().ui.status.contains("picker limit")); + assert_eq!(h.state().session.actions.list, actions); stage(&mut h, &picker); h.get_by_label("Cancel").click(); h.run_steps(3); From 503908fa104d826701aa8a5c35c7598dc0d6af1b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 14:11:25 -0800 Subject: [PATCH 067/384] Distinguish absent Image Size menu intent in ATN tests --- crates/engine/tests/image_size_scale_styles_atn.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index 4f65bcc34eb..33b333d4230 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -125,7 +125,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() { } } -/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes. +/// Absent settings retain menu intent; present empty or malformed descriptors stay unsupported. #[test] fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { for event in ["ImgS", "imageSize"] { @@ -143,9 +143,15 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { let original = atn::parse(&source).unwrap(); let mut s = Session::new(); let loaded = load(&mut s, &source); - assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + if original.actions[0].items[0].descriptor.is_none() { + assert_eq!(loaded["unsupported"], json!([])); + let kind = if event == "ImgS" { "code" } else { "name" }; + assert_eq!(s.actions.list[0].steps, vec![(atn_cmds::MENU_ITEM.into(), json!({"id":"image.imageSize","eventKind":kind}))]); + } else { + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{event}: {:?}", original.actions[0].items[0].descriptor); + assert_eq!(s.actions.list[0].steps.len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + } let saved = atn::parse(&save(&mut s)).unwrap(); assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); From 2f801162caa9d55d58ed7f55e6d4b4f03132e14b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 14:34:30 -0800 Subject: [PATCH 068/384] Refuse loading Actions while a recording is suspended --- crates/ui-egui/src/panels/actions.rs | 1 + crates/ui-egui/src/panels/actions/tests.rs | 59 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 976d23b8332..6a55b211b1f 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -814,6 +814,7 @@ pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), Strin | "actions.duplicate" | "actions.clearAll" | "actions.replace" + | "actions.load" | "actions.recordAgain" | "actions.reset" ) && suspended(app) diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index e35999dedc1..0baadd34fcf 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1581,6 +1581,65 @@ fn pause_child(app: &mut PhotocraftApp) -> u64 { v["paused"]["dialog"].as_u64().unwrap() } +/// A delayed picker cannot replace the action that owns a suspended call; OK and Cancel still +/// restore that recording and journal the call exactly once. +#[test] +fn bulk_load_reply_cannot_replace_a_suspended_recording() { + use crate::file_dialog::{FileDialogAnswer, FileDialogReply}; + use std::{cell::RefCell, rc::Rc}; + + for confirm in [false, true] { + let mut app = recording_with_dialog_child(); + for action in &mut app.session.actions.list { + action.set = Some("Shared".into()); + } + let waiting: Rc>> = Default::default(); + let reply = waiting.clone(); + app.services.file_dialog = Some(Box::new(move |_, _, answer| *reply.borrow_mut() = Some(answer))); + let ctx = egui::Context::default(); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(waiting.borrow().is_some(), "the picker is already awaiting an answer"); + + let dialog = pause_child(&mut app); + let before = app.session.actions.clone(); + let fields = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + let record = app.paused_playbacks.paused.values().next().unwrap().record.clone(); + let journal = app.session.journal.clone(); + let panel = app.ui.actions.clone(); + assert!(before.recording.is_none() && record.is_some()); + waiting + .borrow_mut() + .take() + .unwrap() + .send(Some(FileDialogAnswer::ContentsMany(vec![("replacement.atn".into(), action_set_bytes("Shared", "Replacement"))]))); + app.poll_file_dialog(&ctx, None); + + assert!(app.ui.status_error); + let refusal = crate::i18n::t("Confirm or cancel the open action step dialog first").to_string(); + assert!(app.ui.status.contains(refusal.as_str()), "{}", app.ui.status); + assert!(!app.file_dialog_open()); + assert_eq!(app.session.actions, before, "a refused same-set load cannot replace the library or recording state"); + assert_eq!(app.ui.actions, panel); + assert_eq!(app.session.journal, journal); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields, fields); + assert_eq!(app.paused_playbacks.paused.len(), 1); + assert_eq!(app.paused_playbacks.paused.values().next().unwrap().record, record); + + if confirm { + crate::dialogs::confirm(&mut app, dialog).unwrap(); + } else { + crate::dialogs::cancel(&mut app, dialog).unwrap(); + } + assert!(app.session.actions.recording.is_some()); + assert!(app.paused_playbacks.paused.is_empty()); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].name, "Owner"); + assert_eq!(app.session.actions.list[1].steps, child_call()); + assert_eq!(suffixes(&app), usize::from(confirm)); + } +} + #[test] fn a_recorded_call_paused_by_a_dialog_records_one_call_and_replays_once() { let mut app = recording_with_dialog_child(); From 87238953ec715a033651e54e0e3bccba55cb7e59 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 14:57:49 -0800 Subject: [PATCH 069/384] Preserve imported menu item names when saving Actions --- crates/engine/src/cmd/actions_cmds.rs | 4 +- crates/engine/src/cmd/atn_cmds/menu_item.rs | 5 +- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 29 ++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 12 ++- .../tests/image_size_scale_styles_atn.rs | 9 +- crates/engine/tests/it/atn_menu_item.rs | 86 ++++++++++++++++++- crates/ui-egui/tests/it/atn_menu_item.rs | 3 + 7 files changed, 129 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 37005120374..ec7fdf3d62b 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -140,11 +140,11 @@ pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value { } /// Resolve the recorded visibility target identically in engine and shell playback, and drop -/// the `.atn` event a Filter Gallery step remembers for Save Actions: the command, its journal +/// the `.atn` event a Filter Gallery or menu step remembers for Save Actions: the command, its journal /// entry (Last Filter), a smart filter and the step's dialog never see it. pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { let mut p = p.clone(); - if id.starts_with("filter.gallery.") + if (id.starts_with("filter.gallery.") || id == crate::atn_cmds::MENU_ITEM) && let Some(object) = p.as_object_mut() { object.remove(crate::atn_cmds::RECORDED_EVENT); diff --git a/crates/engine/src/cmd/atn_cmds/menu_item.rs b/crates/engine/src/cmd/atn_cmds/menu_item.rs index d982cec9971..b32da02ccf6 100644 --- a/crates/engine/src/cmd/atn_cmds/menu_item.rs +++ b/crates/engine/src/cmd/atn_cmds/menu_item.rs @@ -29,7 +29,7 @@ pub const MENU_ITEM: &str = "actions.menuItem"; /// Validate menu identity before the shell gates and opens its actual command. pub fn menu_item_command(p: &Value) -> Result<&str> { let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?; - if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind")) { + if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind" | super::RECORDED_EVENT)) { return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings")); } if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) { @@ -64,7 +64,8 @@ pub(super) fn export(p: &Value) -> Option { } else { AtnEvent::name(mapped.name) }; - Some(AtnItem { event, dictionary: mapped.label.as_bytes().to_vec(), descriptor: None, ..Default::default() }) + let (event, dictionary) = super::steps_gallery::recorded_event(super::MENU_ITEM, p).unwrap_or_else(|| (event, mapped.label.as_bytes().to_vec())); + Some(AtnItem { event, dictionary, descriptor: None, ..Default::default() }) } /// Headless execution runs stateless items and refuses settings deliberately left for a dialog. diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index 215fab4a672..9825e54988e 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -592,19 +592,20 @@ fn effect_command(event: &AtnEvent) -> Option<&'static str> { GALLERY.iter().find(|(c, _, _)| *c == code).map(|(_, cmd, _)| *cmd) } -/// The hidden param a step loaded from an effect's own event carries, so it saves back as that -/// event (and panel name) rather than as a `GEfc` Filter Gallery step. Playback strips it +/// The hidden param an imported effect or menu step carries to retain its original event and +/// panel name bytes. Playback strips it /// (`actions_cmds::playback_params`), so the command, the journal and smart filters never see it. pub(crate) const RECORDED_EVENT: &str = "_atnEvent"; -/// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`). -/// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft. +/// Remember an effect's own event or descriptor-free menu event, including its raw panel name. +/// A recorded `GEfc` step carries nothing and saves as `GEfc`, as does a native gallery step. pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { - if !is_effect(&item.event) { - return; - } let [(id, Value::Object(p))] = steps else { return }; - if effect_command(&item.event) != Some(id.as_str()) { + let menu = id == super::MENU_ITEM + && item.descriptor.is_none() + && super::menu_item::from_event(&item.event) + .is_some_and(|expected| p.get("id") == expected.get("id") && p.get("eventKind") == expected.get("eventKind")); + if !menu && effect_command(&item.event) != Some(id.as_str()) { return; } let kind = match item.event { @@ -617,8 +618,8 @@ pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary})); } -/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves -/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +/// Return remembered event/name bytes only while they still match the current command and kind. +/// Otherwise the exporter uses that command's canonical event and name. pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec)> { let r = params.get(RECORDED_EVENT)?; let event = jstr(r, "event")?; @@ -631,7 +632,13 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec< Some(bytes) => bytes, None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(), }; - (effect_command(&event) == Some(id)).then_some((event, name)) + let matches = if id == super::MENU_ITEM { + super::menu_item::from_event(&event) + .is_some_and(|expected| params.get("id") == expected.get("id") && params.get("eventKind") == expected.get("eventKind")) + } else { + effect_command(&event) == Some(id) + }; + matches.then_some((event, name)) } /// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index ad8355ef340..cbbbdad0153 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -1145,7 +1145,11 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, PHOTOSHOP_STEP]); - assert_eq!(imp.actions[0].steps[4].1, json!({"id":"filter.blur.gaussianBlur","eventKind":"code"})); + assert_eq!( + imp.actions[0].steps[4].1, + json!({"id":"filter.blur.gaussianBlur","eventKind":"code", + "_atnEvent":{"event":"GsnB","eventKind":"code","name":file.actions[0].items[4].dictionary(),"dictionary":b64(&file.actions[0].items[4].dictionary)}}) + ); assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } @@ -3580,7 +3584,11 @@ fn camera_raw_filter_steps_with_unknown_keys_stay_photoshop_steps() { assert_eq!(unsupported, [0, 1], "{loaded}"); let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.cameraRaw"]); - assert_eq!(s.actions.list[0].steps[2].1, json!({"id":"filter.cameraRaw","eventKind":"name"})); + assert_eq!( + s.actions.list[0].steps[2].1, + json!({"id":"filter.cameraRaw","eventKind":"name", + "_atnEvent":{"event":"Adobe Camera Raw Filter","eventKind":"name","name":"Camera Raw Filter","dictionary":b64(b"Camera Raw Filter")}}) + ); // Kept, it still saves back as recorded. let r = s.execute("actions.save", json!({"action": "A"})).unwrap(); let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index 33b333d4230..429765001f8 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -146,7 +146,14 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { if original.actions[0].items[0].descriptor.is_none() { assert_eq!(loaded["unsupported"], json!([])); let kind = if event == "ImgS" { "code" } else { "name" }; - assert_eq!(s.actions.list[0].steps, vec![(atn_cmds::MENU_ITEM.into(), json!({"id":"image.imageSize","eventKind":kind}))]); + assert_eq!( + s.actions.list[0].steps, + vec![( + atn_cmds::MENU_ITEM.into(), + json!({"id":"image.imageSize","eventKind":kind, + "_atnEvent":{"event":event,"eventKind":kind,"name":"Image Size","dictionary":photocraft_engine::paint::tile::b64_encode(b"Image Size")}}) + )] + ); } else { assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{event}: {:?}", original.actions[0].items[0].descriptor); assert_eq!(s.actions.list[0].steps.len(), 1); diff --git a/crates/engine/tests/it/atn_menu_item.rs b/crates/engine/tests/it/atn_menu_item.rs index 6b6193b4c6f..971d8b78eae 100644 --- a/crates/engine/tests/it/atn_menu_item.rs +++ b/crates/engine/tests/it/atn_menu_item.rs @@ -26,7 +26,14 @@ fn absent_image_size_settings_round_trip_and_require_interaction_without_edits() let imported = atn_cmds::import(&bytes).unwrap(); assert!(imported.unsupported.is_empty(), "{:?}", imported.unsupported); let action = &imported.actions[0]; - assert_eq!(action.steps, vec![("actions.menuItem".into(), json!({"id": "image.imageSize", "eventKind": kind}))]); + assert_eq!( + action.steps, + vec![( + "actions.menuItem".into(), + json!({"id": "image.imageSize", "eventKind": kind, + "_atnEvent":{"event":event.id(),"eventKind":kind,"name":"","dictionary":""}}) + )] + ); let (saved, skipped) = atn_cmds::export("Menu", &[action], &[], false).unwrap(); assert!(skipped.is_empty()); let reparsed = atn::parse(&saved).unwrap(); @@ -93,3 +100,80 @@ fn empty_target_and_other_event_forms_keep_their_existing_meaning() { assert_eq!(result["ran"], 1, "{result}"); assert_eq!(session.active().unwrap().doc.size, photocraft_doc::Size::new(4, 3)); } + +/// Imported panel names retain their exact bytes; save metadata never becomes command input. +/// The gallery case exercises the existing user of the shared event/name representation. +#[test] +fn imported_menu_names_keep_raw_bytes_without_leaking_into_playback() { + let gallery = Descriptor::new("null") + .with("GEfk", D::Enumerated { type_id: Id::new("GEft"), value: Id::new("Ct") }) + .with("NmbL", D::Integer(6)) + .with("EdgS", D::Integer(4)) + .with("EdgF", D::Integer(2)); + let cases = [ + (AtnEvent::Code(*b"ImgS"), None, atn_cmds::MENU_ITEM, "image.imageSize"), + (AtnEvent::name("imageSize"), None, atn_cmds::MENU_ITEM, "image.imageSize"), + (AtnEvent::Code(*b"GsnB"), None, atn_cmds::MENU_ITEM, "filter.blur.gaussianBlur"), + (AtnEvent::name("findEdges"), None, atn_cmds::MENU_ITEM, "filter.stylize.findEdges"), + (AtnEvent::name("cutout"), Some(gallery), "filter.gallery.cutout", "filter.gallery.cutout"), + ]; + for dictionary in ["画像サイズ".as_bytes().to_vec(), vec![0x83, 0x8a, 0xff, 0], Vec::new()] { + for (event, descriptor, id, command) in &cases { + let bytes = file(AtnItem { event: event.clone(), dictionary: dictionary.clone(), descriptor: descriptor.clone(), ..Default::default() }); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + let (step_id, params) = &imported.actions[0].steps[0]; + assert_eq!(step_id, id); + assert_eq!(params["_atnEvent"]["dictionary"], json!(photocraft_engine::paint::tile::b64_encode(&dictionary))); + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let execution = photocraft_engine::actions_cmds::playback_params(&session, step_id, params).unwrap(); + assert!(execution.get("_atnEvent").is_none()); + if *id == atn_cmds::MENU_ITEM { + let kind = if matches!(event, AtnEvent::Code(_)) { "code" } else { "name" }; + assert_eq!(execution, json!({"id":command,"eventKind":kind})); + assert_eq!(atn_cmds::menu_item_command(params).unwrap(), *command); + } + session.actions.list = imported.actions; + let result = session.execute("actions.play", json!({"action": "Resize"})).unwrap(); + if *id == atn_cmds::MENU_ITEM && atn_cmds::menu_item_needs_settings(command) { + assert_eq!(result["ran"], 0); + assert!(result["failed"]["error"].as_str().unwrap().contains("interactive-required")); + } else { + assert_eq!(result["ran"], 1, "{result}"); + assert!(result.get("failed").is_none()); + } + assert!(session.journal.iter().all(|(_, p)| p.get("_atnEvent").is_none())); + let (saved, skipped) = atn_cmds::export("Menu", &[&session.actions.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let reparsed = atn::parse(&saved).unwrap(); + let item = &reparsed.actions[0].items[0]; + assert_eq!(&item.event, event); + assert_eq!(item.dictionary, dictionary); + assert_eq!(item.descriptor.is_none(), descriptor.is_none()); + } + } +} + +/// Native choices keep canonical labels, and stale saved event metadata cannot retarget them. +#[test] +fn native_menu_names_stay_canonical_and_ignore_mismatched_saved_events() { + let native = json!({"id":"image.imageSize"}); + for metadata in [None, Some(json!({"event":"GsnB","eventKind":"code"})), Some(json!({"event":"imageSize","eventKind":"name"}))] { + let mut params = native.clone(); + if let Some(mut metadata) = metadata { + params["eventKind"] = json!("code"); + metadata["dictionary"] = json!(photocraft_engine::paint::tile::b64_encode(b"wrong label")); + params["_atnEvent"] = metadata; + } + let expected = if params.get("eventKind").is_some() { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name("imageSize") }; + let action = Action::new("Native", vec![(atn_cmds::MENU_ITEM.into(), params)]); + let (saved, skipped) = atn_cmds::export("Menu", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&saved).unwrap(); + let item = &parsed.actions[0].items[0]; + assert_eq!(item.event, expected); + assert_eq!(item.dictionary, b"Image Size"); + assert!(item.descriptor.is_none()); + } +} diff --git a/crates/ui-egui/tests/it/atn_menu_item.rs b/crates/ui-egui/tests/it/atn_menu_item.rs index e811e64efb1..3a601e7327f 100644 --- a/crates/ui-egui/tests/it/atn_menu_item.rs +++ b/crates/ui-egui/tests/it/atn_menu_item.rs @@ -22,12 +22,14 @@ fn image_size_menu_item_opens_real_dialog_cancel_stops_and_confirm_resumes() { PhotocraftApp::setup_context(&h.ctx, Default::default()); let revision = h.state().session.active().unwrap().revision; let definition = h.state().session.actions.list[0].steps.clone(); + assert!(definition[0].1.get("_atnEvent").is_some(), "the imported definition keeps save-only metadata"); let result = h.state_mut().run("actions.play", json!({"action": "Menu"})).unwrap(); let dialog = result["paused"]["dialog"].as_u64().unwrap(); h.run_steps(3); let fields = &h.state().ui.dialogs.iter().find(|d| d.id == dialog).unwrap().fields; assert_eq!(fields["__command"], "image.imageSize"); assert_eq!(fields["__sizing"], true, "the populated sizing dialog, not a generic confirmation"); + assert!(fields.get("_atnEvent").is_none(), "save-only metadata never enters the command editor"); assert_eq!(fields["width"], 8.0); assert_eq!(fields["height"], 6.0); h.get_by_label("Cancel").click(); @@ -50,6 +52,7 @@ fn image_size_menu_item_opens_real_dialog_cancel_stops_and_confirm_resumes() { assert_eq!(doc.size, photocraft_doc::Size::new(4, 3)); assert!(doc.selection.is_some(), "the following step runs only after confirmation"); assert_eq!(h.state().session.actions.list[0].steps, definition, "menu playback does not record the entered settings into the definition"); + assert!(h.state().session.journal.iter().all(|(_, p)| p.get("_atnEvent").is_none()), "save-only metadata stays out of executed commands"); } /// Permit the wrapper while denying the concrete resize command it would open. From f2e5d3f8fa42f9d26b2bbfa273718f85c6b27615 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 15:12:13 -0800 Subject: [PATCH 070/384] Bound staged browser Load Actions selections before reading --- apps/photocraft-web/src/picked_file.rs | 60 ++++++++++++++++++++ apps/photocraft-web/src/web.rs | 31 +++++------ crates/ui-egui/src/dialogs.rs | 8 ++- crates/ui-egui/src/dialogs/filter_dialog.rs | 2 +- crates/ui-egui/src/dialogs/stack_dialog.rs | 15 +++-- crates/ui-egui/src/file_dialog.rs | 7 ++- crates/ui-egui/src/file_dialog/tests.rs | 4 +- crates/ui-egui/src/panels/actions.rs | 26 ++++++++- crates/ui-egui/src/panels/actions/tests.rs | 61 ++++++++++++++++++++- crates/ui-egui/src/smart_svg_ui.rs | 1 + 10 files changed, 183 insertions(+), 32 deletions(-) diff --git a/apps/photocraft-web/src/picked_file.rs b/apps/photocraft-web/src/picked_file.rs index ad183cbc6c5..64479576f6d 100644 --- a/apps/photocraft-web/src/picked_file.rs +++ b/apps/photocraft-web/src/picked_file.rs @@ -15,6 +15,27 @@ where Ok((name, read().await)) } +/// Preflight every immutable browser size before creating the future that reads any selection. +/// The optional staging budget is separate from the per-file cap enforced by [`read`]. +pub async fn read_contents(sizes: impl IntoIterator, max: Option, read: F) -> Result)>, String> +where + F: FnOnce() -> Fut, + Fut: Future)>, String>>, +{ + if let Some(max) = max { + let total = sizes.into_iter().try_fold(0u64, |sum, size| { + if !size.is_finite() || size < 0.0 || size.fract() != 0.0 || size >= u64::MAX as f64 { + return None; + } + sum.checked_add(size as u64) + }); + if total.is_none_or(|total| total > max) { + return Err(format!("the selected files are larger than {max} bytes or their total size is invalid")); + } + } + read().await +} + #[cfg(test)] mod tests { use super::*; @@ -53,4 +74,43 @@ mod tests { } } } + + /// Check all metadata before even creating the read future, including a late excess and + /// overflowing totals. Huge sizes here are numbers only; the reader returns four tiny bytes. + #[test] + fn contents_budget_preflights_the_entire_selection_before_any_read() { + let cases = [ + (vec![2.0, 2.0], Some(4), true), + (vec![2.0, 3.0], Some(4), false), + (vec![0.0, 0.0], Some(0), true), + (vec![], Some(0), true), + (vec![(512u64 << 20) as f64], Some(512 << 20), true), + (vec![(512u64 << 20) as f64, 1.0], Some(512 << 20), false), + (vec![2f64.powi(63), 2f64.powi(63)], Some(u64::MAX), false), + (vec![2f64.powi(64)], Some(u64::MAX), false), + (vec![f64::NAN], Some(4), false), + (vec![f64::INFINITY], Some(4), false), + (vec![-1.0], Some(4), false), + (vec![0.5], Some(4), false), + (vec![f64::NAN, f64::INFINITY], None, true), + ]; + for (sizes, max, allowed) in cases { + let called = Cell::new(false); + let files = vec![("first.atn".into(), vec![1, 2]), ("last.atn".into(), vec![3, 4])]; + let future = read_contents(sizes, max, || { + assert!(allowed, "no file can be read from a refused selection"); + called.set(true); + std::future::ready(Ok(files.clone())) + }); + let mut future = std::pin::pin!(future); + let Poll::Ready(result) = future.as_mut().poll(&mut Context::from_waker(Waker::noop())) else { panic!("an immediate read") }; + assert_eq!(called.get(), allowed); + assert_eq!(result.is_ok(), allowed); + if allowed { + assert_eq!(result.unwrap(), files, "selection order and payloads are unchanged"); + } else { + assert!(result.unwrap_err().contains("selected files")); + } + } + } } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index c320b67f1a7..a28cbddc604 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -419,7 +419,7 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu file_dialog: Some(Box::new(|request, _parent, reply| match request { // The browser's file picker hands over the files' contents, not paths. File › Open // asks for several files (`multiple`), each opened in its own tab. - FileDialogRequest::Open { multiple, extensions, max_bytes, .. } => wasm_bindgen_futures::spawn_local(async move { + FileDialogRequest::Open { multiple, extensions, max_bytes, max_contents_bytes, .. } => wasm_bindgen_futures::spawn_local(async move { let dialog = if let Some(exts) = extensions { rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts) } else { @@ -428,24 +428,19 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu let picked = if multiple { dialog.pick_files().await } else { dialog.pick_file().await.map(|file| vec![file]) }; let answer = match picked { Some(picked) if !picked.is_empty() => { - let mut files = Vec::with_capacity(picked.len()); - let mut error = None; - for file in picked { - // File/Blob size is immutable; reject before rfd creates its ArrayBuffer/Vec. - match crate::picked_file::read(file.file_name(), file.inner().size(), max_bytes, || file.read()).await { - Ok(file) => files.push(file), - Err(why) => { - error = Some(why); - break; - } + let contents = crate::picked_file::read_contents(picked.iter().map(|file| file.inner().size()), max_contents_bytes, || async { + let mut files = Vec::with_capacity(picked.len()); + for file in &picked { + // File/Blob size is immutable; each cap precedes its ArrayBuffer/Vec. + files.push(crate::picked_file::read(file.file_name(), file.inner().size(), max_bytes, || file.read()).await?); } - } - if let Some(why) = error { - Some(FileDialogAnswer::Unavailable(why)) - } else if multiple { - Some(FileDialogAnswer::ContentsMany(files)) - } else { - files.pop().map(|(name, bytes)| FileDialogAnswer::Contents(name, bytes)) + Ok(files) + }) + .await; + match contents { + Err(why) => Some(FileDialogAnswer::Unavailable(why)), + Ok(files) if multiple => Some(FileDialogAnswer::ContentsMany(files)), + Ok(mut files) => files.pop().map(|(name, bytes)| FileDialogAnswer::Contents(name, bytes)), } } _ => None, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 83ff29f1059..a8cc332a59f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,7 +26,13 @@ fn browse_field(app: &mut PhotocraftApp, id: u64, fields: &serde_json::Map fill_field(app, id, &key, paths.remove(0)), _ => Err("Install Plug-in reads a .wasm file from disk".into()), diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index d052e5f3a5a..d023966f7f9 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -1099,7 +1099,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, dialog: u64, command: St use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; let doc = app.active_doc_id()?; let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); - app.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions: None, max_bytes: None }, move |app, answer| { + app.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions: None, max_bytes: None, max_contents_bytes: None }, move |app, answer| { let path = match answer { FileDialogAnswer::Paths(paths) => paths.into_iter().next().ok_or("no displacement map was chosen")?, FileDialogAnswer::Unavailable(e) => return Err(e), diff --git a/crates/ui-egui/src/dialogs/stack_dialog.rs b/crates/ui-egui/src/dialogs/stack_dialog.rs index cdbd8006336..80eb14502de 100644 --- a/crates/ui-egui/src/dialogs/stack_dialog.rs +++ b/crates/ui-egui/src/dialogs/stack_dialog.rs @@ -98,12 +98,15 @@ fn browse(app: &mut PhotocraftApp, id: u64, folder: bool) { }) } else { let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); - app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None, max_bytes: None }, move |app, answer| { - let FileDialogAnswer::Paths(paths) = answer else { return Err("Load Files into Stack reads files from disk".into()) }; - let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; - add(&mut d.fields, paths); - Ok(Value::Null) - }) + app.ask_file( + FileDialogRequest::Open { multiple: true, initial_dir, extensions: None, max_bytes: None, max_contents_bytes: None }, + move |app, answer| { + let FileDialogAnswer::Paths(paths) = answer else { return Err("Load Files into Stack reads files from disk".into()) }; + let d = app.ui.dialog_mut(id).ok_or("the dialog was closed")?; + add(&mut d.fields, paths); + Ok(Value::Null) + }, + ) }; if let Err(e) = r.map(|_| ()).or_else(|e| if e == crate::file_dialog::CANCELLED { Ok(()) } else { Err(e) }) { app.ui.status = e; diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index e93686642c6..3351a56cb9a 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -32,6 +32,9 @@ pub enum FileDialogRequest { extensions: Option>, /// Per-file limit: byte-producing services must check size before reading; consumers recheck. max_bytes: Option, + /// Aggregate staged contents limit; byte-producing services preflight before any read. + /// Path-producing services do not stage payloads and leave this to the contents consumer. + max_contents_bytes: Option, }, /// Which folder to read from. Starts in `initial_dir` when available. Folder { initial_dir: Option }, @@ -250,7 +253,7 @@ impl PhotocraftApp { ) -> Result { let initial_dir = last_used_dir(&self.ui.recent_files); let extensions = (!extensions.is_empty()).then(|| extensions.iter().map(|s| (*s).to_string()).collect()); - self.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions, max_bytes: max }, move |app, answer| { + self.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions, max_bytes: max, max_contents_bytes: None }, move |app, answer| { let (name, bytes) = read_picked(answer, max)?; then(app, name, bytes) }) @@ -259,7 +262,7 @@ impl PhotocraftApp { /// File › Open: opens every chosen file, reporting each failure (see [`Self::open_paths`]). pub fn open_dialog_file(&mut self) -> Result { let initial_dir = last_used_dir(&self.ui.recent_files); - self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None, max_bytes: None }, |app, answer| { + self.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: None, max_bytes: None, max_contents_bytes: None }, |app, answer| { match answer { FileDialogAnswer::Paths(paths) => { app.open_paths(&paths); diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 418f899ac7c..02076f84975 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -378,7 +378,7 @@ fn open_starts_in_the_last_used_folder() { menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap(); app.poll_file_dialog(&ctx, None); assert!( - matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: true, initial_dir: Some(dir), extensions: None, max_bytes: None }, _)] if dir == "/pics"), + matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: true, initial_dir: Some(dir), extensions: None, max_bytes: None, max_contents_bytes: None }, _)] if dir == "/pics"), "{:?}", open.borrow().first().map(|(r, _)| r.clone()) ); @@ -1516,7 +1516,7 @@ fn picked_file_limit_refuses_before_callback_and_keeps_other_callers_unchanged() app.pick_file_bytes_filtered(&["atn"], then).unwrap(); } app.poll_file_dialog(&ctx, None); - assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { max_bytes, .. }, _)] if *max_bytes == max)); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { max_bytes, max_contents_bytes: None, .. }, _)] if *max_bytes == max)); let bounded_path = max.is_some() && matches!(chosen, FileDialogAnswer::Paths(_)); answer(&open, Some(chosen)); app.poll_file_dialog(&ctx, None); diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 6a55b211b1f..01f8a0a5d00 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -558,10 +558,34 @@ fn load_source(app: &mut PhotocraftApp, name: &str, params: Value) -> Result Result { + load_dialog_with_limit(app, MAX_LOAD_CONTENTS_BYTES) +} + +/// Carry the selection budget through the producer and recheck byte-producing custom services. +fn load_dialog_with_limit(app: &mut PhotocraftApp, max_contents_bytes: u64) -> Result { let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); - app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: Some(vec!["atn".into()]), max_bytes: None }, |app, answer| { + let request = FileDialogRequest::Open { + multiple: true, + initial_dir, + extensions: Some(vec!["atn".into()]), + max_bytes: None, + max_contents_bytes: Some(max_contents_bytes), + }; + app.ask_file(request, move |app, answer| { + let total = match &answer { + FileDialogAnswer::Contents(_, bytes) => Some(bytes.len() as u64), + FileDialogAnswer::ContentsMany(files) => files.iter().try_fold(0u64, |sum, (_, bytes)| sum.checked_add(bytes.len() as u64)), + _ => Some(0), // Native paths are read one at a time, not staged as contents. + }; + if total.is_none_or(|total| total > max_contents_bytes) { + return Err(format!("the selected files are larger than {max_contents_bytes} bytes or their total size is invalid")); + } let files: Vec<_> = match answer { FileDialogAnswer::Paths(paths) => paths.into_iter().map(|name| (name, None)).collect(), FileDialogAnswer::Contents(name, bytes) => vec![(name, Some(bytes))], diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 0baadd34fcf..c2415dec707 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -26,7 +26,9 @@ fn bulk_load_imports_every_chosen_action_file() { load_dialog(&mut app).unwrap(); app.poll_file_dialog(&egui::Context::default(), None); assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["First", "Second"]); - assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { multiple: true, extensions: Some(ext), .. }] if ext == &["atn"])); + assert!( + matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { multiple: true, extensions: Some(ext), max_bytes: None, max_contents_bytes: Some(MAX_LOAD_CONTENTS_BYTES), .. }] if ext == &["atn"]) + ); assert!(!app.file_dialog_open()); assert!(!app.ui.status_error, "{}", app.ui.status); } @@ -2032,3 +2034,60 @@ fn bulk_load_summary_counts_unsupported_steps_of_surviving_sets() { assert_eq!(names, ["A", "B"]); assert!(app.session.actions.list[0].steps.iter().any(|(id, _)| id == "actions.photoshopStep")); } + +/// Byte-producing providers must stay within the whole-selection budget before the first +/// set is parsed or replaced; exact-size single and multiple selections still import normally. +#[test] +fn bulk_load_rechecks_total_contents_before_importing_any_set() { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + let first = ("replacement.atn".to_string(), action_set_bytes("Shared", "Replacement")); + let last = ("later.atn".to_string(), action_set_bytes("Other", "Later")); + for multiple in [false, true] { + for allowed in [false, true] { + let total = first.1.len() as u64 + if multiple { last.1.len() as u64 } else { 0 }; + let max = total - u64::from(!allowed); + let answer = if multiple { + FileDialogAnswer::ContentsMany(vec![first.clone(), last.clone()]) + } else { + FileDialogAnswer::Contents(first.0.clone(), first.1.clone()) + }; + let (show, asked) = crate::file_dialog::fake(vec![Some(answer)]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + let mut original = Action::new("Original", vec![]); + original.set = Some("Shared".into()); + app.session.actions.list.push(original); + app.ui.actions.selected = Some(0); + let before = app.session.actions.clone(); + let panel = app.ui.actions.clone(); + let journal = app.session.journal.clone(); + load_dialog_with_limit(&mut app, max).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { max_bytes: None, max_contents_bytes: Some(limit), .. }] if *limit == max)); + assert!(!app.file_dialog_open()); + assert_eq!(app.ui.status_error, !allowed, "{}", app.ui.status); + if allowed { + let names: Vec<_> = app.session.actions.list.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, if multiple { vec!["Replacement", "Later"] } else { vec!["Replacement"] }); + } else { + assert!(app.ui.status.contains("selected files"), "{}", app.ui.status); + assert_eq!(app.session.actions, before, "not even the first same-set replacement may run"); + assert_eq!(app.ui.actions, panel); + assert_eq!(app.session.journal, journal); + } + } + } +} + +/// Native path services do not retain a whole selection's payloads in the picker. +#[test] +fn bulk_load_contents_budget_does_not_limit_native_paths() { + use crate::file_dialog::FileDialogAnswer; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("native.atn"); + std::fs::write(&path, action_set_bytes("Native", "From disk")).unwrap(); + let mut app = disk_app(vec![Some(FileDialogAnswer::Paths(vec![path.to_string_lossy().into_owned()]))]); + load_dialog_with_limit(&mut app, 0).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(app.session.actions.list[0].name, "From disk"); +} diff --git a/crates/ui-egui/src/smart_svg_ui.rs b/crates/ui-egui/src/smart_svg_ui.rs index f48d539c25d..d783fa04296 100644 --- a/crates/ui-egui/src/smart_svg_ui.rs +++ b/crates/ui-egui/src/smart_svg_ui.rs @@ -227,6 +227,7 @@ fn choose_program(app: &mut PhotocraftApp) -> Result { initial_dir: None, extensions: if cfg!(windows) { Some(vec!["exe".into()]) } else { None }, max_bytes: None, + max_contents_bytes: None, }, move |app, answer| { let crate::file_dialog::FileDialogAnswer::Paths(paths) = answer else { return Err("Choose a local editor executable".into()) }; From 49ac4082b8fdfffbedff5a0cb80157a2f12ec973 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 15:33:44 -0800 Subject: [PATCH 071/384] Classify Insert Path as document geometry in automation registry checks --- crates/automation/src/workspace/tests.rs | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index 05b6f36691d..025a9ea2e7b 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -530,6 +530,35 @@ fn registry_filesystem_path_params_are_classified() { assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}"); } +/// Insert Path's `name` resolves a document vector path, even when the name resembles a file; +/// filesystem-bearing Actions commands still require the existing automation policy. +#[test] +fn insert_path_captures_document_geometry_without_filesystem_authority() { + let spec = photocraft_engine::command_specs().iter().find(|spec| spec.id == "actions.insertPath").unwrap(); + assert!(!documents_filesystem_path_params(spec.id, spec.params)); + for id in ["actions.load", "actions.save"] { + let spec = photocraft_engine::command_specs().iter().find(|spec| spec.id == id).unwrap(); + assert!(documents_filesystem_path_params(spec.id, spec.params)); + assert!(authorize_engine_command(id, &serde_json::json!({"path":"/outside/never-read.atn"})).is_err()); + assert!(authorize_desktop_engine_command(id, &serde_json::json!({"path":"/outside/never-read.atn"})).is_err()); + } + let mut headless = crate::Headless::new(); // No filesystem capability is granted. + headless.command_run("file.new", serde_json::json!({"width":8,"height":6})).unwrap(); + headless.session.actions.list.push(photocraft_engine::actions_cmds::Action::new("Capture", vec![])); + let name = "/outside/document-curve"; + let path = serde_json::json!({"fillRule":"evenodd","subpaths":[{"closed":false,"knots":[{"anchor":[1.0,2.0],"out":[3.0,4.0]},{"anchor":[6.0,5.0],"in":[4.0,3.0]}]}]}); + headless.command_run("path.set", serde_json::json!({"name":name,"path":path})).unwrap(); + let expected = headless.command_run("path.info", serde_json::json!({"name":name})).unwrap()["path"].clone(); + let revision = headless.session.active().unwrap().revision; + headless.command_run("actions.insertPath", serde_json::json!({"action":"Capture","name":name})).unwrap(); + assert_eq!(headless.session.active().unwrap().revision, revision); + assert_eq!(headless.session.actions.list[0].steps, vec![("path.set".into(), serde_json::json!({"name":"work","path":expected}))]); + let played = headless.command_run("actions.play", serde_json::json!({"action":"Capture"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none()); + assert_eq!(headless.command_run("path.info", serde_json::json!({"name":"work"})).unwrap()["path"], expected); +} + #[test] fn native_vector_preview_updates_are_not_an_untrusted_automation_capability() { let params = serde_json::json!({"document":1,"layer":1,"contentsId":1,"fingerprint":"00".repeat(32),"extension":"ai","sourceBase64":"JVBERi0=","previewSvg":""}); @@ -677,6 +706,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "path.addToSelection" | "path.subtractFromSelection" | "path.set" + | "actions.insertPath" | "path.transform" | "path.moveAnchors" | "path.moveHandle" From e6391775bc72b110400688a58146f3aacf43599b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 17:51:33 -0800 Subject: [PATCH 072/384] Protect paused Actions libraries and bound replacement staging --- crates/engine/tests/it/actions_panel_menu.rs | 6 +- crates/ui-egui/src/panels/actions.rs | 18 ++---- crates/ui-egui/src/panels/actions/replace.rs | 7 ++- crates/ui-egui/tests/it/actions_replace.rs | 2 +- crates/ui-egui/tests/it/atn_menu_item.rs | 65 ++++++++++++++++++++ docs/atn.md | 8 +++ 6 files changed, 90 insertions(+), 16 deletions(-) diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs index 8ba9e42544c..6fca4270486 100644 --- a/crates/engine/tests/it/actions_panel_menu.rs +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -111,7 +111,11 @@ fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() let raw = photocraft_psd::atn::parse(&bytes).unwrap(); assert!(raw.actions[0].items[0].descriptor.is_none()); let imported = atn_cmds::import(&bytes).unwrap(); - assert_eq!(imported.actions[0].steps[0], (atn_cmds::MENU_ITEM.into(), json!({"id":id,"eventKind":kind}))); + let (command, mut params) = imported.actions[0].steps[0].clone(); + assert!(params.as_object_mut().unwrap().remove("_atnEvent").is_some(), "the imported step retains its save-only source metadata"); + assert_eq!((command, params), (atn_cmds::MENU_ITEM.into(), json!({"id":id,"eventKind":kind}))); + let exported = atn_cmds::export("S", &[&imported.actions[0]], &[], false).unwrap().0; + assert_eq!(photocraft_psd::atn::parse(&exported).unwrap().actions[0].items, raw.actions[0].items); let played = s.execute("actions.play", json!({"action":"A"})).unwrap(); assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}"); } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 01f8a0a5d00..0630fa9c5d4 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -825,23 +825,15 @@ fn suspended(app: &PhotocraftApp) -> bool { app.paused_playbacks.paused.values().any(|p| p.record.is_some()) } -/// While a step dialog holds a suspended recording, refuse what would lose its call: a new +/// Whole-library changes cannot invalidate the action a paused dialog will resume by name. While a step dialog +/// holds a suspended recording, also refuse what would lose its call: a new /// recording (the restore would find recording taken) or deleting/moving/duplicating actions (the recorded /// action could go away or change place). The engine checks these against `recording`, which /// is `None` then. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!( - id, - "actions.record" - | "actions.delete" - | "actions.move" - | "actions.duplicate" - | "actions.clearAll" - | "actions.replace" - | "actions.load" - | "actions.recordAgain" - | "actions.reset" - ) && suspended(app) + let changes_library = matches!(id, "actions.load" | "actions.clearAll" | "actions.replace" | "actions.reset"); + if (changes_library && !app.paused_playbacks.paused.is_empty()) + || (matches!(id, "actions.record" | "actions.delete" | "actions.move" | "actions.duplicate" | "actions.recordAgain") && suspended(app)) { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } diff --git a/crates/ui-egui/src/panels/actions/replace.rs b/crates/ui-egui/src/panels/actions/replace.rs index 23dd31a0622..09449c7fe1e 100644 --- a/crates/ui-egui/src/panels/actions/replace.rs +++ b/crates/ui-egui/src/panels/actions/replace.rs @@ -6,6 +6,11 @@ use crate::{PhotocraftApp, state::DialogKind}; const COMMAND: &str = "actions.replace"; const MARK: &str = "__replaceActions"; +// Raw input and its base64 transport overlap during staging and decoding. Three raw bytes +// need four encoded bytes; allow at most the shared 512 MiB input cap for both together. +// This limits the picker, not Load Actions or the engine's separate per-file import cap. +const MAX_PICKED_BYTES: u64 = photocraft_engine::preset_import_cmds::MAX_FILE_BYTES / 7 * 3; + /// Frozen input and authority, kept out of editable and inspectable dialog fields. pub(crate) struct Held { dialog: u64, @@ -46,7 +51,7 @@ pub(super) fn open(app: &mut PhotocraftApp) -> Result { let gate = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }); let automated = app.automation_input || gate.is_some(); authorize(app, &json!({"confirm": true}), gate, automated)?; - app.pick_file_bytes_with_limit(&["atn"], Some(photocraft_engine::preset_import_cmds::MAX_FILE_BYTES), move |app, name, bytes| { + app.pick_file_bytes_with_limit(&["atn"], Some(MAX_PICKED_BYTES), move |app, name, bytes| { idle(app)?; if app.session.actions.rev != revision || app.held_actions_replace.is_some() { return Err(tl!("The actions changed. Request Replace Actions again.").into()); diff --git a/crates/ui-egui/tests/it/actions_replace.rs b/crates/ui-egui/tests/it/actions_replace.rs index 0d08f0fd2fd..298b95296ba 100644 --- a/crates/ui-egui/tests/it/actions_replace.rs +++ b/crates/ui-egui/tests/it/actions_replace.rs @@ -56,7 +56,7 @@ fn pick(h: &mut Harness<'static, PhotocraftApp>, picker: &Picker) { h.get_by_role_and_label(egui::accesskit::Role::Button, "Replace Actions…").click(); h.run_steps(3); assert!(matches!(picker.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, extensions: Some(ext), max_bytes: Some(max), .. }, _)] - if ext == &["atn"] && *max == photocraft_engine::preset_import_cmds::MAX_FILE_BYTES)); + if ext == &["atn"] && *max > 0 && *max + max.div_ceil(3) * 4 <= photocraft_engine::preset_import_cmds::MAX_FILE_BYTES)); } /// Deliver a platform answer through the real continuation, without bypassing staging. diff --git a/crates/ui-egui/tests/it/atn_menu_item.rs b/crates/ui-egui/tests/it/atn_menu_item.rs index 3a601e7327f..e54fba9a7c5 100644 --- a/crates/ui-egui/tests/it/atn_menu_item.rs +++ b/crates/ui-egui/tests/it/atn_menu_item.rs @@ -15,6 +15,71 @@ fn app() -> PhotocraftApp { app } +/// Direct loads and delayed picker replies cannot replace the action a non-recording dialog owns. +#[test] +fn loading_actions_waits_for_a_paused_menu_step_without_recording() { + use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply}; + use std::{cell::RefCell, rc::Rc}; + + let replacement = Action::new("Menu", vec![("select.deselect".into(), json!({}))]); + let bytes = atn_cmds::export("Menu", &[&replacement], &[], false).unwrap().0; + for delayed in [false, true] { + for confirm in [false, true] { + let mut app = app(); + let waiting: Rc>> = Default::default(); + let reply = waiting.clone(); + app.services.file_dialog = Some(Box::new(move |_, _, answer| *reply.borrow_mut() = Some(answer))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 760.0)).build_ui_state( + |ui, app| { + actions::panel_menu(app, ui); + app.poll_file_dialog(ui.ctx(), None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(2); + if delayed { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Load Actions…").click(); + h.run_steps(3); + assert!(waiting.borrow().is_some()); + } + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let paused = app.run("actions.play", json!({"action":"Menu"})).unwrap(); + let dialog = paused["paused"]["dialog"].as_u64().unwrap(); + let before = app.session.actions.clone(); + let document = app.session.active().unwrap().doc.clone(); + let fields = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + assert!(before.recording.is_none(), "this pause has no suspended recording"); + if delayed { + waiting.borrow_mut().take().unwrap().send(Some(FileDialogAnswer::Contents("replacement.atn".into(), bytes.clone()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("Confirm or cancel"), "{}", app.ui.status); + } else { + let error = app.run("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap_err(); + assert!(error.contains("Confirm or cancel"), "{error}"); + } + for command in ["actions.clearAll", "actions.replace", "actions.reset"] { + let error = app.run(command, json!({"confirm":true,"mode":"replace"})).unwrap_err(); + assert!(error.contains("Confirm or cancel"), "{command}: {error}"); + } + assert_eq!(app.session.actions, before); + assert_eq!(app.session.active().unwrap().doc, document); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields, fields); + if confirm { + dialogs::confirm(app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "the original action's tail resumes"); + } else { + dialogs::cancel(app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_none()); + } + app.run("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(app.session.actions.list[0].steps, replacement.steps, "loading works after the pause ends"); + } + } +} + /// The real sizing dialog cancels without edits and resumes only after confirmation. #[test] fn image_size_menu_item_opens_real_dialog_cancel_stops_and_confirm_resumes() { diff --git a/docs/atn.md b/docs/atn.md index 99f883fc989..5a6601ff904 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -566,6 +566,14 @@ Measured with a folder `in` holding `a.png`, `b.png`, `notes.txt` and `sub/a.png ### Actions panel menu +Loading or replacing the library, Clear All and Reset wait until any open action-step dialog is +confirmed or cancelled, including playback without recording. Other actions in a paused set may +still be edited; changed actions stop the remainder of the set rather than silently running replacements. + +The Replace Actions picker accepts files up to 230,087,532 bytes (about 219 MiB). Its raw bytes and +base64 command data together stay below 512 MiB; parsed actions and other application memory are +outside that bound. Load Actions and the engine's separate 512 MiB per-file import cap are unchanged. + Button Mode; New Action, New Set, Duplicate, Delete, Play; Start Recording, Record Again, Insert Menu Item, Insert Stop, Insert Conditional, Insert Path; Action Options, Playback Options, Allow Tool Recording; Clear All Actions, Reset Actions, Load Actions, Replace Actions, Save Actions; the bundled sets From b5770e42831371e19bcba700da923bdc49aa8d78 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:38:09 +0100 Subject: [PATCH 073/384] CLI: --metadata none|all for convert, run --out and batch (#9466) Since #733 a flat export from the CLI keeps the document's whole XMP packet and free-form text, as Save As does; only Export As starts at Metadata: None. Batch exports of Photoshop masters therefore carry the full DocumentAncestors list (and the text of every type layer), which can be many times larger than the image, with no way to leave it out. `--metadata` is a new export option shared by `convert`, `run --out` and `batch`. `none` sets ExportOptions::xmp to XmpEmbed::None, the same path Export As's Metadata: None and Quick Export use, so the XMP packet and free-form text are dropped while the ICC profile, EXIF and resolution are written as before; PSD, PSB and .pcraft keep the XMP either way. `all` is the default, so existing scripts write the same files. Any other value is a usage error (exit 2) naming the flag, before anything is read or written. Tested through the binary for PNG, WebP, JPEG and TIFF from all three subcommands (packet present by default and with `all`, absent with `none`; pixels, ICC and resolution unchanged), layered saves, and bad values. Documented in the CLI book page and file-format parity doc. --- apps/photocraft-cli/src/args.rs | 18 ++++++- apps/photocraft-cli/src/output.rs | 4 ++ apps/photocraft-cli/tests/it/cli.rs | 73 +++++++++++++++++++++++++++++ book/src/automation/cli.md | 16 +++++++ docs/file-format-parity.md | 9 ++-- 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 33175a15be9..a5de7bbc68b 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -16,7 +16,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars}; use photocraft_codecs::{JpegFormat, TiffCompression}; -use photocraft_io::{DdsCompression, DpxBits, TgaBits}; +use photocraft_io::{DdsCompression, DpxBits, TgaBits, XmpEmbed}; use serde_json::Value; /// photocraft-cli: headless PhotoCraft @@ -78,6 +78,12 @@ pub(crate) struct ExportArgs { /// composite/full.png; it may be omitted. Check the manifest's full_composite member. #[arg(long)] pub(crate) pcraft_full_composite: bool, + /// The document metadata a flat export embeds: all (the default) keeps the document's XMP + /// packet and free-form text, as Save As does; none leaves them out, as Export As's Metadata: + /// None. The ICC profile, EXIF and resolution are kept either way, and PSD, PSB and .pcraft + /// always keep the XMP + #[arg(long, value_name = "none|all", value_parser = metadata)] + pub(crate) metadata: Option, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -268,6 +274,8 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + #[error("expected none or all")] + Metadata, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, @@ -326,6 +334,14 @@ fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } +fn metadata(s: &str) -> Result { + match s { + "none" => Ok(XmpEmbed::None), + "all" => Ok(XmpEmbed::All), + _ => Err(ValueError::Metadata), + } +} + /// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:` for a port. #[derive(Clone)] pub(crate) enum Bridge { diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 831258b3155..f2964696559 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -26,6 +26,10 @@ impl From<&ExportArgs> for ExportOptions { o.encode.dds.compression = c; } o.encode.dds.mipmaps = a.dds_mipmaps; + // Conversions keep the whole packet unless asked, as Save As does (#733, #9466). + if let Some(m) = a.metadata { + o.xmp = m; + } o } } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 176fb970fdb..caad46edc10 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -758,6 +758,79 @@ fn jpeg_format_chooses_the_scans() { } } +/// #9466: `--metadata none` leaves the document's XMP packet out of a flat export from `convert`, +/// `run --out` and `batch`, as Export As's Metadata: None does, and changes nothing else (pixels, +/// ICC profile, resolution). The default, and `all`, keep it as before, and a PSD or `.pcraft` +/// keeps it either way. A value it doesn't name is a usage error and nothing is written. +#[test] +fn metadata_none_leaves_out_the_document_xmp() { + const XMP: &str = "\ +\ +xmp.did:11111111-2222-3333-4444-555555555555\ +"; + let d = tmp("metadata"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + let a = input.join("a.png"); + let data: Vec = (0..8 * 6 * 4).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect(); + let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); + img.meta.xmp = Some(XMP.into()); + img.meta.dpi = Some((300.0, 300.0)); + std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); + let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); + let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); + assert!(ancestors(&a), "the input carries the packet"); + let invert = r#"[{"command":"image.adjustments.invert"}]"#; + for ext in ["png", "webp", "jpg", "tif"] { + let mut converted_files = Vec::new(); + for flag in [None, Some("all"), Some("none")] { + let extra: Vec<&str> = flag.map(|f| vec!["--metadata", f]).unwrap_or_default(); + let name = format!("{ext}-{}", flag.unwrap_or("default")); + let converted = d.join(format!("convert-{name}.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&converted).args(&extra)); + let ran = d.join(format!("run-{name}.{ext}")); + ok(bin().arg("run").arg(&a).arg("--out").arg(&ran).args(&extra)); + let o = batch_with(&d, &input, &format!("batch-{name}"), invert, &[&["--format", ext][..], &extra[..]].concat()); + assert!(o.status.success(), "batch {name}: {}", String::from_utf8_lossy(&o.stderr)); + let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}")); + for out in [&converted, &ran, &batched] { + assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display()); + } + converted_files.push(converted); + } + // Only the XMP goes: the pixels, profile and resolution are the default export's. + let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); + assert_eq!(none.data(), default.data(), "{ext}: pixels"); + assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); + assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); + assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); + } + // A layered save keeps the document's XMP whatever the option. + for ext in ["psd", "pcraft"] { + let out = d.join(format!("layered.{ext}")); + ok(bin().arg("convert").arg(&a).arg(&out).args(["--metadata", "none"])); + let back = d.join(format!("layered-{ext}.png")); + ok(bin().arg("convert").arg(&out).arg(&back)); + assert!(ancestors(&back), "{ext} kept the packet"); + } + for bad in ["None", "exif", "0", ""] { + let out = d.join("bad.png"); + let (mut convert, mut run) = (bin(), bin()); + convert.arg("convert").arg(&a).arg(&out).args(["--metadata", bad]); + run.arg("run").arg(&a).arg("--out").arg(&out).args(["--metadata", bad]); + for cmd in [&mut convert, &mut run] { + let o = cmd.output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "--metadata {bad}: {err}"); + assert!(err.contains("--metadata") && err.contains("expected none or all"), "--metadata {bad}: {err}"); + assert!(!out.exists(), "--metadata {bad} wrote a file"); + } + let o = batch_with(&d, &input, "bad-batch", invert, &["--metadata", bad]); + assert_eq!(o.status.code(), Some(2), "batch --metadata {bad}"); + assert!(!d.join("bad-batch").exists(), "batch --metadata {bad} wrote a folder"); + } +} + /// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before /// anything is written, unless `--in-place` asks for it. #[test] diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 6137b12466a..2f8c3628d75 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -35,6 +35,22 @@ Warnings do not change the saved document's font declarations and do not stop the operation. To list or replace the missing families intentionally, use `type.resolveMissingFonts` on the document. +### Document metadata + +`convert`, `run --out` and `batch` keep the document's XMP packet and +free-form text in a flat export (PNG, JPEG, WebP, TIFF, …), as Save As does. +`--metadata none` leaves them out, as Export As's Metadata: None does: a +Photoshop file's XMP can list every placed document's id +(`DocumentAncestors`) and the text of every type layer, and can be far larger +than the image. `--metadata all` is the default. The option changes nothing +else: the ICC profile, EXIF and resolution are written either way, and PSD, +PSB and `.pcraft` saves always keep the XMP. It is not a full metadata scrub. + +```sh +photocraft-cli batch --actions resize.json --in masters/ --out web/ \ + --format webp --metadata none +``` + ### Export selected adjustment layers as a .cube LUT The engine command `file.export.colorLookupTables` creates a 3D LUT from an diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0821f154eb..c586cb0b608 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-03 · **Change:** minor (CLI `--metadata none|all` for `convert`, `run --out` and `batch`: `none` leaves the document XMP and free-form text out of flat exports, as Export As's Metadata: None, `all` the default, #9466; `.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -27,11 +27,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | -| PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | +| PNG | R/W | yes | yes | 8/16-bit, ICC/text (the CLI's `--metadata none` leaves the document XMP and text out, #9466); APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP (the CLI's `--metadata none` leaves the document XMP out, #9466); Save keeps an opened file lossy (at its quality) or lossless (#2399); Save for Web (Legacy) writes it too, lossy at its Quality or lossless, a format Photoshop's legacy dialog lacks (#6391). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -151,6 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. ICC, EXIF and resolution are written either way; PSD, PSB and `.pcraft` always keep the XMP (#9466) | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | From a99e8696fc720e2dd8050163d36cbf076fc83eba Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:59:20 +0100 Subject: [PATCH 074/384] CLI --metadata: qualify the docs per format, test text and ICC (#9466) Review follow-up on #9975. The help text and the CLI book page said the XMP and free-form text are kept in every flat export and the ICC profile, EXIF and resolution "either way". Not every format holds them: JPEG and WebP have no place for free-form text, TIFF writes only its named text tags, and an Indexed Color document's palette PNG carries no XMP, text, ICC or EXIF at all, only its resolution. The texts (and the parity-doc revision row) now say the XMP and text are kept where the format holds them and that the option leaves everything else as it is without it. The test fixture gains a free-form text entry and a Display P3 profile (from photocraft-cms, now a dev-dependency; no profile file committed), so it checks the text is gone with `none` and kept by a default PNG, and that the default export keeps a real ICC profile that `none` matches, instead of comparing None with None. --- Cargo.lock | 1 + apps/photocraft-cli/Cargo.toml | 2 ++ apps/photocraft-cli/src/args.rs | 6 +++--- apps/photocraft-cli/tests/it/cli.rs | 20 ++++++++++++++------ book/src/automation/cli.md | 17 +++++++++++------ docs/file-format-parity.md | 2 +- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab0417b79b8..0fab1b2a97a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5102,6 +5102,7 @@ dependencies = [ "clap_complete", "clap_mangen", "photocraft-automation", + "photocraft-cms", "photocraft-codecs", "photocraft-engine", "photocraft-format", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 94e04b518ad..19ef8bfa926 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -34,6 +34,8 @@ thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] +# A real ICC profile for the --metadata test (#9466). +photocraft-cms = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index a5de7bbc68b..4ba08fbaae4 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -79,9 +79,9 @@ pub(crate) struct ExportArgs { #[arg(long)] pub(crate) pcraft_full_composite: bool, /// The document metadata a flat export embeds: all (the default) keeps the document's XMP - /// packet and free-form text, as Save As does; none leaves them out, as Export As's Metadata: - /// None. The ICC profile, EXIF and resolution are kept either way, and PSD, PSB and .pcraft - /// always keep the XMP + /// packet and free-form text where the format holds them, as Save As does; none leaves them + /// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and + /// resolution are written as they are without it. PSD, PSB and .pcraft always keep the XMP #[arg(long, value_name = "none|all", value_parser = metadata)] pub(crate) metadata: Option, } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index caad46edc10..cffeed06441 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -758,10 +758,11 @@ fn jpeg_format_chooses_the_scans() { } } -/// #9466: `--metadata none` leaves the document's XMP packet out of a flat export from `convert`, -/// `run --out` and `batch`, as Export As's Metadata: None does, and changes nothing else (pixels, -/// ICC profile, resolution). The default, and `all`, keep it as before, and a PSD or `.pcraft` -/// keeps it either way. A value it doesn't name is a usage error and nothing is written. +/// #9466: `--metadata none` leaves the document's XMP packet and free-form text out of a flat +/// export from `convert`, `run --out` and `batch`, as Export As's Metadata: None does, and changes +/// nothing else (pixels, ICC profile, resolution). The default, and `all`, keep them as before (the +/// text where the format holds it, as PNG does), and a PSD or `.pcraft` keeps the XMP either way. +/// A value it doesn't name is a usage error and nothing is written. #[test] fn metadata_none_leaves_out_the_document_xmp() { const XMP: &str = "\ @@ -776,10 +777,14 @@ fn metadata_none_leaves_out_the_document_xmp() { let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap(); img.meta.xmp = Some(XMP.into()); img.meta.dpi = Some((300.0, 300.0)); + img.meta.text = vec![("Comment".into(), "free-form-note".into())]; + // Display P3 rather than sRGB, so no export can pass for keeping it by writing its default. + img.icc = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec()); std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); - assert!(ancestors(&a), "the input carries the packet"); + let note = |p: &Path| decoded(p).meta.text.iter().any(|(_, v)| v == "free-form-note"); + assert!(ancestors(&a) && note(&a), "the input carries the packet and the text"); let invert = r#"[{"command":"image.adjustments.invert"}]"#; for ext in ["png", "webp", "jpg", "tif"] { let mut converted_files = Vec::new(); @@ -795,12 +800,15 @@ fn metadata_none_leaves_out_the_document_xmp() { let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}")); for out in [&converted, &ran, &batched] { assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display()); + // PNG holds free-form text; JPEG, WebP and TIFF (but for its named tags) don't. + assert_eq!(note(out), ext == "png" && flag != Some("none"), "{name} text: {}", out.display()); } converted_files.push(converted); } - // Only the XMP goes: the pixels, profile and resolution are the default export's. + // Only the XMP and text go: the pixels, profile and resolution are the default export's. let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); assert_eq!(none.data(), default.data(), "{ext}: pixels"); + assert!(default.icc.is_some(), "{ext}: the default export keeps the ICC profile"); assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); diff --git a/book/src/automation/cli.md b/book/src/automation/cli.md index 2f8c3628d75..c7278455ef5 100644 --- a/book/src/automation/cli.md +++ b/book/src/automation/cli.md @@ -37,14 +37,19 @@ the operation. To list or replace the missing families intentionally, use ### Document metadata -`convert`, `run --out` and `batch` keep the document's XMP packet and -free-form text in a flat export (PNG, JPEG, WebP, TIFF, …), as Save As does. -`--metadata none` leaves them out, as Export As's Metadata: None does: a -Photoshop file's XMP can list every placed document's id +`convert`, `run --out` and `batch` keep the document's XMP packet in a flat +export whose format holds one (PNG, JPEG, WebP, TIFF, …), and its free-form +text where the format holds that too (PNG text chunks, TIFF's named text tags +such as ImageDescription; JPEG and WebP have no place for it), as Save As +does. `--metadata none` leaves them out, as Export As's Metadata: None does: +a Photoshop file's XMP can list every placed document's id (`DocumentAncestors`) and the text of every type layer, and can be far larger than the image. `--metadata all` is the default. The option changes nothing -else: the ICC profile, EXIF and resolution are written either way, and PSD, -PSB and `.pcraft` saves always keep the XMP. It is not a full metadata scrub. +else: the ICC profile, EXIF and resolution are written as they are without +it, where the format holds them. An Indexed Color document saved as a palette +PNG carries no XMP, text, ICC profile or EXIF with either value, only its +resolution. PSD, PSB and `.pcraft` saves always keep the XMP. It is not a full +metadata scrub. ```sh photocraft-cli batch --actions resize.json --in masters/ --out web/ \ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c586cb0b608..3fcbc6eb84a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -151,7 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| -| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. ICC, EXIF and resolution are written either way; PSD, PSB and `.pcraft` always keep the XMP (#9466) | +| 2026-11-03 | minor | CLI `--metadata` (`none` or `all`) for `convert`, `run --out` and `batch`: `none` leaves the document's XMP packet and free-form text out of flat exports (PNG, JPEG, WebP, TIFF, …) through Export As's Metadata: None path; `all`, which keeps them as Save As does, stays the default. The ICC profile, EXIF and resolution are written as they are without the option (a palette PNG carries only its resolution, either way); PSD, PSB and `.pcraft` always keep the XMP (#9466) | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | From a112beda7e4f11f9e936fd30bbc8ac9f472ecfe1 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:09:08 +0100 Subject: [PATCH 075/384] CLI --metadata test: the export keeps the input's own ICC profile Review nitpick (#9466): the test only checked that the default export had some ICC profile and that `none` matched it, so a regression writing sRGB instead of the input's Display P3 would still pass. It now compares the default export's profile with the input's bytes. --- apps/photocraft-cli/tests/it/cli.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index cffeed06441..f830c25dc7e 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -779,7 +779,8 @@ fn metadata_none_leaves_out_the_document_xmp() { img.meta.dpi = Some((300.0, 300.0)); img.meta.text = vec![("Comment".into(), "free-form-note".into())]; // Display P3 rather than sRGB, so no export can pass for keeping it by writing its default. - img.icc = Some(photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec()); + let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec(); + img.icc = Some(p3.clone()); std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors"); let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap(); @@ -808,7 +809,7 @@ fn metadata_none_leaves_out_the_document_xmp() { // Only the XMP and text go: the pixels, profile and resolution are the default export's. let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2])); assert_eq!(none.data(), default.data(), "{ext}: pixels"); - assert!(default.icc.is_some(), "{ext}: the default export keeps the ICC profile"); + assert_eq!(default.icc.as_deref(), Some(&p3[..]), "{ext}: the default export keeps the input's Display P3 profile"); assert_eq!(none.icc, default.icc, "{ext}: ICC profile"); assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution"); assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi); From c135363fe652e63f663dcd01d32cd4c7d9f2e66c Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 02:49:08 -0800 Subject: [PATCH 076/384] Preserve action header colours through ATN load and save --- crates/engine/src/cmd/actions_cmds.rs | 32 ++- crates/engine/src/cmd/atn_cmds/convert.rs | 5 +- crates/engine/tests/it/atn_action_colors.rs | 191 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/panels/actions.rs | 4 +- .../panels/actions/metadata_color_tests.rs | 35 ++++ docs/atn.md | 16 ++ 7 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/it/atn_action_colors.rs create mode 100644 crates/ui-egui/src/panels/actions/metadata_color_tests.rs diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index d0be63bf5e8..5e5eaa3b3c5 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -45,13 +45,33 @@ pub struct Action { /// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`). #[serde(default, skip_serializing_if = "Option::is_none")] pub set: Option, - /// Button Mode colour. Empty is none; otherwise one of [`BUTTON_COLORS`]. + /// Action colour: one of [`BUTTON_COLORS`], or an opaque `@atn-color:N` imported header index. #[serde(default, skip_serializing_if = "String::is_empty")] pub color: String, } -/// Button Mode colours, in Photoshop's order. Empty is no colour. -pub const BUTTON_COLORS: [&str; 8] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray"]; +/// Measured action colours in ATN index order. The classic picker offers the first eight. +pub const BUTTON_COLORS: [&str; 12] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray", "teal", "blue2", "magenta", "purple"]; + +const OPAQUE_COLOR_PREFIX: &str = "@atn-color:"; + +/// Keep unknown u16 header indices in the existing String field without naming a new palette colour. +pub(crate) fn color_from_atn(index: u16) -> String { + BUTTON_COLORS.get(usize::from(index)).map_or_else(|| format!("{OPAQUE_COLOR_PREFIX}{index}"), |name| (*name).to_string()) +} + +/// Only measured names or canonical unknown-index metadata may be written to an ATN header. +pub(crate) fn color_to_atn(color: &str) -> Option { + if let Some(index) = BUTTON_COLORS.iter().position(|name| *name == color) { + return u16::try_from(index).ok(); + } + let number = color.strip_prefix(OPAQUE_COLOR_PREFIX)?; + if number.len() > 5 { + return None; // A decimal u16 has at most five digits. + } + let index = number.parse::().ok()?; + (usize::from(index) >= BUTTON_COLORS.len() && number == index.to_string()).then_some(index) +} impl Action { pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { @@ -739,7 +759,7 @@ fn set_color(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "needs \"color\"")); }; if !BUTTON_COLORS.contains(&color) { - return Err(bad(cmd, "color must be empty, red, orange, yellow, green, blue, violet, or gray")); + return Err(bad(cmd, "color must be empty, red, orange, yellow, green, blue, violet, gray, teal, blue2, magenta, or purple")); } let Some(action) = s.actions.list.get_mut(idx) else { return Err(bad(cmd, "no such action")); @@ -777,7 +797,7 @@ pub fn specs() -> Vec { label: "Get Action", menu: &[], shortcut: None, - params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is the Button Mode swatch, or empty)"##, + params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is a measured action swatch, empty, or reserved @atn-color:N metadata preserving an unknown imported u16 index)"##, enabled: always, run: get, journal: false, @@ -847,7 +867,7 @@ pub fn specs() -> Vec { label: "Set Action Color", menu: &[], shortcut: None, - params: r##"{"action":name|index, "color":""|"red"|"orange"|"yellow"|"green"|"blue"|"violet"|"gray"} → {action, color}. The Button Mode colour. Empty clears it."##, + params: r##"{"action":name|index, "color":""|"red"|"orange"|"yellow"|"green"|"blue"|"violet"|"gray"|"teal"|"blue2"|"magenta"|"purple"} → {action, color}. Measured ATN indices 0..11 in that order; blue is classic light blue (5), blue2 is modern blue (9). Empty clears it. Reserved @atn-color:N import metadata cannot be selected."##, enabled: always, run: set_color, journal: false, diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 4b22515c09d..71d00c11595 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -2,6 +2,7 @@ use super::descriptors::{desc, get_obj, jstr}; use super::events::{event_and_descriptor, event_for}; use super::fields::Mapped; use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, D, DEFAULT_SET, Descriptor, PHOTOSHOP_STEP, UnicodeString, Value, atn, json}; +use crate::actions_cmds::{color_from_atn, color_to_atn}; // ------------------------------------------------------------------ import @@ -68,6 +69,7 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); + action.color = color_from_atn(a.color); for (i, item) in a.items.iter().enumerate() { let mapped = match (event_for(&item.event), &item.descriptor) { (Some(e), Some(d)) => (e.from)(d), @@ -226,7 +228,8 @@ pub fn export( let mut skipped = Vec::new(); let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new(), ..Default::default() }; for a in actions { - let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; + let color = color_to_atn(&a.color).ok_or_else(|| format!("action `{}` has an invalid colour", a.name))?; + let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, color, ..Default::default() }; if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { (out.function_key, out.shift, out.command) = (n, shift, command); } diff --git a/crates/engine/tests/it/atn_action_colors.rs b/crates/engine/tests/it/atn_action_colors.rs new file mode 100644 index 00000000000..520f5e0c50b --- /dev/null +++ b/crates/engine/tests/it/atn_action_colors.rs @@ -0,0 +1,191 @@ +//! Action-header colours survive the public ATN and native action-library paths. +use photocraft_engine::{ + Session, + actions_cmds::{Action, ActionsFile}, + atn_cmds, + preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, UnicodeString, Value as D}, +}; +use serde_json::json; + +const SET: &str = "Colour set"; +const NAME: &str = "Colour action"; + +fn recorded(color: u16) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul(SET), + expanded: true, + actions: vec![AtnAction { + name: UnicodeString::new_nul(NAME), + function_key: 6, + shift: true, + command: true, + color, + items: vec![AtnItem { + event: AtnEvent::name("futureColourFixture"), + dictionary: b"Colour fixture".to_vec(), + enabled: false, + with_dialog: true, + dialog_options: 2, + descriptor: Some(Descriptor::new("null").with("data", D::RawData(vec![3, 1, 4])).with("test", D::Integer(7))), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + } +} + +fn load(s: &mut Session, color: u16) { + let bytes = atn::write(&recorded(color)).unwrap(); + let data = photocraft_engine::paint::tile::b64_encode(&bytes); + s.execute("actions.load", json!({"data": data})).unwrap(); +} + +fn saved(s: &mut Session) -> AtnFile { + let result = s.execute("actions.save", json!({"set": SET})).unwrap(); + assert_eq!(result["skipped"], json!([])); + let bytes = photocraft_engine::paint::tile::b64_decode(result["data"].as_str().unwrap()).unwrap(); + atn::parse(&bytes).unwrap() +} + +fn same_step_wire(actual: &AtnItem, expected: &AtnItem) { + assert_eq!(actual, expected); + assert_eq!(actual.descriptor.as_ref().unwrap().to_bytes(), expected.descriptor.as_ref().unwrap().to_bytes()); +} + +/// Save through the user-facing commands before checking the imported colour, exposing real wire loss. +#[test] +fn recorded_red_survives_public_load_and_save_without_other_edits() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let original_shortcuts = s.prefs().shortcuts.clone(); + load(&mut s, 1); + let actions = s.actions.list.clone(); + let revision = s.actions.rev; + let shortcuts = s.prefs().shortcuts.clone(); + let output = saved(&mut s); + assert_eq!(output.actions[0].color, 1, "public Load/Save Actions discarded the red header colour"); + assert_eq!(s.execute("actions.get", json!({"action": NAME})).unwrap()["color"], "red"); + let expected = recorded(1); + assert_eq!(output, expected); + same_step_wire(&output.actions[0].items[0], &expected.actions[0].items[0]); + assert_eq!(atn_cmds::import(&atn::write(&output).unwrap()).unwrap().actions, actions); + assert_eq!(s.actions.list, actions); + assert_eq!(s.actions.rev, revision); + assert_eq!(s.prefs().shortcuts, shortcuts); + assert_eq!(shortcuts[&format!("{}{NAME}", photocraft_engine::actions_cmds::PLAY_SHORTCUT_PREFIX)], "Cmd+Shift+F6"); + for (key, value) in original_shortcuts { + assert_eq!(shortcuts.get(&key), Some(&value)); + } + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), past); +} + +/// Expectations come from the measured header indices, including the distinct classic and new blues. +#[test] +fn all_twelve_header_colours_and_named_edits_keep_their_indices() { + for (index, name) in [ + (0, ""), + (1, "red"), + (2, "orange"), + (3, "yellow"), + (4, "green"), + (5, "blue"), + (6, "violet"), + (7, "gray"), + (8, "teal"), + (9, "blue2"), + (10, "magenta"), + (11, "purple"), + ] { + let mut s = Session::new(); + load(&mut s, index); + assert_eq!(s.execute("actions.get", json!({"action": NAME})).unwrap()["color"], name, "index {index}"); + assert_eq!(saved(&mut s).actions[0].color, index); + s.execute("actions.setColor", json!({"action": NAME, "color": name})).unwrap(); + assert_eq!(saved(&mut s).actions[0].color, index); + if index == 5 { + s.execute("actions.setColor", json!({"action": NAME, "color": "blue2"})).unwrap(); + assert_eq!(saved(&mut s).actions[0].color, 9); + s.execute("actions.setColor", json!({"action": NAME, "color": "blue"})).unwrap(); + assert_eq!(saved(&mut s).actions[0].color, 5); + } + } +} + +/// Unknown header values remain opaque through real native-library writes, reloads and a rename. +#[test] +fn unknown_header_colours_survive_native_persistence_and_unrelated_edits() { + for index in [12, u16::MAX] { + let mem = MemBackend::default(); + let mut s = Session::new(); + assert!(s.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + load(&mut s, index); + let opaque = format!("@atn-color:{index}"); + assert_eq!(s.actions.list[0].color, opaque); + s.sync_preset_store(); + let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let native: ActionsFile = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(native.actions, s.actions.list); + let mut reopened = Session::new(); + assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + assert_eq!(reopened.actions.list, native.actions); + reopened.execute("actions.rename", json!({"action": NAME, "name": "Renamed"})).unwrap(); + assert_eq!(reopened.actions.list[0].color, opaque); + reopened.sync_preset_store(); + let mut again = Session::new(); + assert!(again.attach_preset_store(preset_store::open(Box::new(mem))).is_empty()); + assert_eq!(again.actions.list, reopened.actions.list); + let output = saved(&mut again); + assert_eq!(output.actions[0].color, index); + assert_eq!(output.actions[0].name.to_string_lossy(), "Renamed"); + same_step_wire(&output.actions[0].items[0], &recorded(index).actions[0].items[0]); + } +} + +/// Reserved metadata cannot be chosen as a palette colour; malformed JSON colours never become none. +#[test] +fn invalid_colour_edits_and_exports_leave_the_library_unchanged() { + let mut s = Session::new(); + load(&mut s, 1); + for color in ["@atn-color:12", "@atn-color:65535", "unknown", "@atn-color:012", "@atn-color:65536"] { + let before = s.actions.list.clone(); + let revision = s.actions.rev; + let shortcuts = s.prefs().shortcuts.clone(); + assert!(s.execute("actions.setColor", json!({"action": NAME, "color": color})).is_err(), "{color}"); + assert_eq!(s.actions.list, before); + assert_eq!(s.actions.rev, revision); + assert_eq!(s.prefs().shortcuts, shortcuts); + } + for color in [ + "unknown", + "@atn-color:", + "@atn-color:0", + "@atn-color:11", + "@atn-color:012", + "@atn-color:+12", + "@atn-color:-12", + "@atn-color:65536", + "@atn-color:12 ", + "@atn-color:12", + "@atn-color:12.0", + ] { + let action: Action = serde_json::from_value(json!({"name": NAME, "steps": [], "color": color})).unwrap(); + let original = action.clone(); + for skip in [false, true] { + assert!(atn_cmds::export(SET, &[&action], &[], skip).is_err(), "{color}, skipUnsupported={skip}"); + assert_eq!(action, original); + } + } + let old: Action = serde_json::from_value(json!({"name": "Old", "steps": []})).unwrap(); + assert!(old.color.is_empty()); + let (bytes, skipped) = atn_cmds::export(SET, &[&old], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].color, 0); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 467b04720d7..c5fe4e7569f 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod affinity; mod artboard_export_names; +mod atn_action_colors; mod atn_crystallize_cell_size; mod atn_reference_forms; mod atn_ripple_amount; diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index d84d6d96e51..2642773f205 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -416,7 +416,7 @@ fn button_fill(color: &str, t: &Tokens) -> Color32 { } fn button_label_color(color: &str, t: &Tokens) -> Color32 { - if color.is_empty() { t.text } else { Color32::from_rgb(20, 20, 20) } + if matches!(color, "red" | "orange" | "yellow" | "green" | "blue" | "violet" | "gray") { Color32::from_rgb(20, 20, 20) } else { t.text } } fn paint_check(ui: &egui::Ui, rect: Rect, on: bool, t: &Tokens) { @@ -1579,5 +1579,7 @@ fn draw_step_controls(ui: &mut egui::Ui, rect: Rect, step: usize, label: &str, c (check.on_hover_text(tl!("Include in playback")), dialog.on_hover_text(tl!("Show dialog during playback"))) } +#[cfg(test)] +mod metadata_color_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/actions/metadata_color_tests.rs b/crates/ui-egui/src/panels/actions/metadata_color_tests.rs new file mode 100644 index 00000000000..dcf1aa68379 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/metadata_color_tests.rs @@ -0,0 +1,35 @@ +use super::{button_fill, button_label_color}; +use crate::theme::{ThemeKind, Tokens}; +use egui::Color32; + +/// Imported colours without a classic swatch use the theme's complete neutral foreground/background pair. +#[test] +fn metadata_colours_use_readable_neutral_button_tokens() { + for kind in [ThemeKind::Pro, ThemeKind::ProLight] { + let t = Tokens::for_kind(kind); + for color in ["", "teal", "blue2", "magenta", "purple", "@atn-color:12", "@atn-color:65535"] { + assert_eq!(button_fill(color, &t), t.card, "{kind:?}/{color}"); + assert_eq!(button_label_color(color, &t), t.text, "{kind:?}/{color}"); + } + } +} + +/// Metadata preservation does not alter the classic seven named swatches or their existing foreground. +#[test] +fn classic_button_swatches_keep_their_original_colours() { + for kind in [ThemeKind::Pro, ThemeKind::ProLight] { + let t = Tokens::for_kind(kind); + for (color, fill) in [ + ("red", Color32::from_rgb(196, 64, 64)), + ("orange", Color32::from_rgb(204, 120, 40)), + ("yellow", Color32::from_rgb(196, 176, 48)), + ("green", Color32::from_rgb(56, 140, 72)), + ("blue", Color32::from_rgb(48, 104, 176)), + ("violet", Color32::from_rgb(120, 72, 168)), + ("gray", t.text_dim), + ] { + assert_eq!(button_fill(color, &t), fill, "{kind:?}/{color}"); + assert_eq!(button_label_color(color, &t), Color32::from_rgb(20, 20, 20), "{kind:?}/{color}"); + } + } +} diff --git a/docs/atn.md b/docs/atn.md index 99f883fc989..12860ed3d06 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -18,6 +18,22 @@ layers and documents, newer and older events and files, and a measured reference map yet, with everything needed to implement them without the reference application. +## Action header colours + +Load and Save Actions preserve each action's colour index. Indices 0–11 map to +`""`, `red`, `orange`, `yellow`, `green`, `blue`, `violet`, `gray`, `teal`, `blue2`, +`magenta`, and `purple`, respectively. The existing `blue` name remains index 5 +(light blue); `blue2` is the newer blue at index 9. `actions.setColor` accepts +these measured names. The classic colour menu keeps its eight choices. + +An unknown u16 colour index is retained in native action JSON as the reserved +String `@atn-color:N`, where `N` is its canonical decimal index from 12 to 65535. +It survives library reloads and ATN export, but cannot be selected with +`actions.setColor`. Invalid colour strings fail export, including exports with +`skipUnsupported`, rather than silently becoming index 0. New colours and unknown +indices use a neutral theme card in Classic Button Mode until their swatches are +provided by the newer editor. This header support does not parse an action sidecar. + ## Supported events (144) | Code | String ID | Label | PhotoCraft command(s) | From 41b78106273fd30fffda276fb681d7ee568835c1 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 05:33:43 -0800 Subject: [PATCH 077/384] Keep action editor changes in an unpublished draft until Save --- crates/engine/src/cmd/actions_cmds.rs | 118 +++- crates/engine/src/cmd/actions_cmds/editor.rs | 560 ++++++++++++++++++ crates/engine/src/cmd/actions_edit_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds/files.rs | 2 +- crates/engine/src/jobs.rs | 24 +- .../engine/tests/it/actions_editor_draft.rs | 297 ++++++++++ crates/engine/tests/it/main.rs | 1 + docs/atn.md | 38 ++ 8 files changed, 1013 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/actions_cmds/editor.rs create mode 100644 crates/engine/tests/it/actions_editor_draft.rs diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 5e5eaa3b3c5..f674f22f545 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -22,6 +22,14 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +pub mod editor; + +/// A command temporarily owns the recorder while its nested commands run. +pub(crate) enum Recording { + Classic(usize, usize), + Draft { from: usize, owner: u64, take: u64 }, +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -108,6 +116,9 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000; #[serde(default)] pub struct ActionState { pub list: Vec, + /// An unpublished editor value. Neither it nor its capture is part of actions.json. + #[serde(skip)] + pub editor: Option, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -143,7 +154,7 @@ pub struct ActionState { /// Visibility gestures name a layer for replay, since document-local ids change on another image. pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value { - if s.actions.recording.is_none() || id != "layer.setProps" || p.get("visible").is_none() { + if !s.actions.recording_active() || id != "layer.setProps" || p.get("visible").is_none() { return p; } let Some(st) = s.active() else { return p }; @@ -193,6 +204,51 @@ impl ActionState { pub(crate) fn touch(&mut self) { self.rev = self.rev.saturating_add(1); } + + /// Both destinations use the same normalized command journal and job-take ownership. + pub fn recording_active(&self) -> bool { + self.recording.is_some() || self.editor.as_ref().is_some_and(|draft| draft.recording.is_some()) + } + + /// Start of the active recorder's pending journal slice. + pub(crate) fn recording_cursor(&self) -> Option { + self.recording.map(|(from, _)| from).or_else(|| self.editor.as_ref().and_then(|draft| draft.recording)) + } + + /// Suspend either destination so a compound command records one logical step. + pub(crate) fn take_recording(&mut self) -> Option { + if let Some((from, index)) = self.recording.take() { + return Some(Recording::Classic(from, index)); + } + let draft = self.editor.as_mut()?; + let from = draft.recording.take()?; + Some(Recording::Draft { from, owner: draft.id, take: self.take }) + } + + /// Restore only the capture still owned by this command; never revive an ended draft. + pub(crate) fn restore_capture(&mut self, held: Option) { + if self.recording_active() { + return; + } + match held { + Some(Recording::Classic(from, index)) => self.recording = Some((from, index)), + Some(Recording::Draft { from, owner, take }) if self.take == take => { + if let Some(draft) = self.editor.as_mut().filter(|draft| draft.id == owner) { + draft.recording = Some(from); + } + } + _ => {} + } + } + + /// Skip an older job's journal entry without losing this take's own pending steps. + pub(crate) fn advance_recording(&mut self, to: usize) { + if let Some((from, _)) = &mut self.recording { + *from = to; + } else if let Some(from) = self.editor.as_mut().and_then(|draft| draft.recording.as_mut()) { + *from = to; + } + } } /// Replay commands and named action calls, excluding edits to the action definitions and the @@ -378,7 +434,7 @@ fn get(s: &mut Session, p: &Value) -> Result { fn record(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.record")?; - if s.actions.recording.is_some() { + if s.actions.recording_active() { return Err(bad("actions.record", "already recording; stop first")); } let idx = if p.get("action").is_some() { @@ -424,7 +480,7 @@ pub fn pending_steps(s: &Session, action: usize) -> impl Iterator Result<()> { - let Some((_, idx)) = s.actions.recording else { return Ok(()) }; + let Some((_, idx)) = s.actions.recording else { return editor::flush(s) }; if idx >= s.actions.list.len() { return Err(bad("actions.stop", "the action being recorded no longer exists")); } @@ -506,6 +562,12 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option Result> { + // The old shell continuation carries a library index, not a draft owner. The editor + // must stop before using that route; the headless play command suspends the draft + // itself and records a named call without a shell continuation. + if s.actions.editor.as_ref().is_some_and(|draft| draft.recording.is_some()) { + return Err(bad("actions.play", "stop draft recording before shell playback")); + } flush_recording(s)?; if s.actions.playing == 0 { s.actions.playback_steps = 0; @@ -527,6 +589,10 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio /// Resume recording into action `idx` and journal the named call that suspended it. pub fn restore_recording(s: &mut Session, idx: usize, action: &str, from: usize) { s.actions.recording = Some((s.journal.len(), idx)); + journal_call(s, action, from); +} + +fn journal_call(s: &mut Session, action: &str, from: usize) { let mut params = json!({"action": action}); if from > 0 { params["from"] = json!(from); @@ -549,9 +615,29 @@ pub fn nested_failure(id: &str, value: &Value) -> Option { fn play(s: &mut Session, p: &Value) -> Result { let (action, from) = playback_plan(s, p)?; - let recording = begin_playback(s, &action)?; + // actions.play journals its own named call, rather than using dispatch's journaled + // command suspension. Flush earlier draft steps before hiding its capture here. + let draft = if s.actions.editor.as_ref().is_some_and(|draft| draft.recording.is_some()) { + flush_recording(s)?; + s.actions.take_recording() + } else { + None + }; + let recording = match begin_playback(s, &action) { + Ok(recording) => recording, + Err(error) => { + s.actions.restore_capture(draft); + return Err(error); + } + }; let (ran, failed) = run_recorded(s, &action, from); finish_playback(s, recording, &action, from); + if let Some(Recording::Draft { owner, take, .. }) = draft { + s.actions.restore_capture(Some(Recording::Draft { from: s.journal.len(), owner, take })); + if s.actions.take == take && s.actions.editor.as_ref().is_some_and(|draft| draft.id == owner && draft.recording.is_some()) { + journal_call(s, &action.name, from); + } + } Ok(match failed { Some(f) => json!({"action": action.name, "ran": ran, "failed": f}), None => json!({"action": action.name, "ran": ran}), @@ -703,6 +789,15 @@ fn toggle(s: &mut Session, p: &Value) -> Result { /// Prefix of the persisted shortcut override that plays a named action (`actions.play:`). pub const PLAY_SHORTCUT_PREFIX: &str = "actions.play:"; +/// Named action references shared by Rename and an editor's prepared Save. +fn call_keys(id: &str) -> &'static [&'static str] { + match id { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + } +} + /// Rename an action in place. Nested `actions.play` steps and `actions.conditional` branches /// that call it by name, and its function-key binding, follow the new name so nothing silently /// stops working. @@ -722,15 +817,10 @@ fn rename(s: &mut Session, p: &Value) -> Result { if s.actions.list.iter().any(|a| a.name == name) { return Err(bad("actions.rename", "an action with this name already exists")); } - let pending = s.actions.recording.map_or(s.journal.len(), |(start, _)| start); + let pending = s.actions.recording_cursor().unwrap_or(s.journal.len()); let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); for (id, params) in calls { - let keys: &[&str] = match id.as_str() { - "actions.play" => &["action"], - "actions.conditional" => &["then", "else"], - _ => &[], - }; - for key in keys { + for key in call_keys(id) { if params.get(*key).and_then(Value::as_str) == Some(old.as_str()) { params[*key] = json!(name); } @@ -771,7 +861,7 @@ fn set_color(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - vec![ + let mut specs = vec![ CommandSpec { id: "actions.move", label: "Move Action or Step", @@ -872,7 +962,9 @@ pub fn specs() -> Vec { run: set_color, journal: false, }, - ] + ]; + specs.extend(editor::specs()); + specs } #[cfg(test)] diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs new file mode 100644 index 00000000000..b85b68504a9 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -0,0 +1,560 @@ +//! One unpublished action value, a capture-only recorder, and one logical definition Save. +use std::collections::BTreeMap; + +use super::{Action, CommandSpec, Result, Session, Value, bad, json, not_playing, replayable}; + +const OPEN: &str = "actions.editor.open"; +const GET: &str = "actions.editor.get"; +const UPDATE: &str = "actions.editor.update"; +const STEP: &str = "actions.editor.step"; +const RECORD: &str = "actions.editor.record"; +const STOP: &str = "actions.editor.stop"; +const SAVE: &str = "actions.editor.save"; +const DISCARD: &str = "actions.editor.discard"; +const MAX_STEPS: usize = 100_000; +type Bindings = (BTreeMap, BTreeMap>); + +/// Runtime-only editor state. Complete values retain fields introduced by later backend phases. +#[derive(Clone, Debug, PartialEq)] +pub struct Draft { + pub(super) id: u64, + value: Action, + opening: Action, + source: Option, + library_revision: u64, + shortcut: String, + opening_shortcut: String, + keys: BTreeMap, + alternates: BTreeMap>, + pub(super) recording: Option, +} + +/// Only action bindings participate in the editor's shortcut stale guard. +fn action_keys(keys: &BTreeMap) -> BTreeMap { + keys.iter().filter(|(key, _)| key.starts_with(super::PLAY_SHORTCUT_PREFIX)).map(|(key, value)| (key.clone(), value.clone())).collect() +} + +/// Unsupported future fields must fail explicitly instead of being silently discarded. +fn fields(p: &Value, allowed: &[&str], cmd: &str) -> Result<()> { + let Some(object) = p.as_object() else { + return if p.is_null() { Ok(()) } else { Err(bad(cmd, "params must be an object")) }; + }; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(bad(cmd, format!("unsupported editor field `{key}`"))); + } + Ok(()) +} + +/// Mutating requests must name this editor, never whichever editor happens to be open next. +fn owned<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<&'a Draft> { + let id = p.get("draft").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`draft` must be the editor id"))?; + s.actions.editor.as_ref().filter(|draft| draft.id == id).ok_or_else(|| bad(cmd, "this editor is no longer open")) +} + +/// Pending capture uses the same logical-command filter as Classic recording. +fn pending<'a>(s: &'a Session, draft: &Draft) -> impl Iterator { + s.journal.iter().skip(draft.recording.unwrap_or(s.journal.len())).filter(|(id, _)| replayable(id)) +} + +/// Build the visible value without flushing into either destination or changing its cursor. +fn visible(s: &Session, draft: &Draft) -> Result { + let count = pending(s, draft).count(); + if count > 0 && count > MAX_STEPS.saturating_sub(draft.value.steps.len()) { + return Err(bad(RECORD, "the draft exceeds the 100000-step limit")); + } + let mut value = draft.value.clone(); + value.steps.extend(pending(s, draft).cloned()); + Ok(value) +} + +/// Structured inspection serializes the complete Action, not a metadata whitelist. +fn view(draft: &Draft, value: &Action) -> Value { + json!({"draft":draft.id, "action":value, "shortcut":draft.shortcut, + "dirty": value != &draft.opening || draft.shortcut != draft.opening_shortcut, + "recording":draft.recording.is_some(), "source":draft.source, "libraryRevision":draft.library_revision}) +} + +/// Keep untouched legacy names, but validate newly supplied names before changing a draft. +fn name(value: &Value, old: Option<&str>, cmd: &str) -> Result { + let text = value.as_str().ok_or_else(|| bad(cmd, "`name` must be a string"))?; + if Some(text) != old && (text.trim().is_empty() || text.len() > 65_536) { + return Err(bad(cmd, "a new name must be nonblank and at most 65536 bytes")); + } + Ok(text.to_string()) +} + +/// Allocate only the displayed default name; opening does not create or reserve a library row. +fn new_value(s: &Session, p: &Value) -> Result { + let name = match p.get("name") { + Some(value) => name(value, None, OPEN)?, + None => { + let mut n = s.actions.list.len().saturating_add(1); + while s.actions.list.iter().any(|action| action.name == format!("Action {n}")) { + n = n.checked_add(1).ok_or_else(|| bad(OPEN, "cannot allocate an action name"))?; + } + format!("Action {n}") + } + }; + if s.actions.list.iter().any(|action| action.name == name) { + return Err(bad(OPEN, "an action with this name already exists")); + } + let set = match p.get("set") { + None | Some(Value::Null) => None, + Some(Value::String(set)) if crate::actions_edit_cmds::set_names(s).contains(set) => Some(set.clone()), + Some(Value::String(set)) if !set.trim().is_empty() => Some(set.trim().to_string()), + Some(_) => return Err(bad(OPEN, "`set` must be a nonblank name or null")), + }; + Ok(Action { set, ..Action::new(name, Vec::new()) }) +} + +/// Open one complete action clone, without changing the library, shortcuts or preset store. +fn open(s: &mut Session, p: &Value) -> Result { + fields(p, &["action", "name", "set"], OPEN)?; + not_playing(s, OPEN)?; + if s.actions.editor.is_some() || s.actions.recording_active() { + return Err(bad(OPEN, "close the open editor or stop recording first")); + } + let source = p.get("action").map(|_| super::resolve(&s.actions.list, p, OPEN)).transpose()?; + if source.is_some() && (p.get("name").is_some() || p.get("set").is_some()) { + return Err(bad(OPEN, "open an existing action without replacement name/set fields")); + } + let value = match source { + Some(index) => s.actions.list.get(index).cloned().ok_or_else(|| bad(OPEN, "no such action"))?, + None => new_value(s, p)?, + }; + let id = s.actions.take.checked_add(1).ok_or_else(|| bad(OPEN, "recording identities exhausted"))?; + let shortcut = s.prefs().shortcuts.get(&format!("{}{}", super::PLAY_SHORTCUT_PREFIX, value.name)).cloned().unwrap_or_default(); + let draft = Draft { + id, + opening: value.clone(), + value, + source, + library_revision: s.actions.rev, + opening_shortcut: shortcut.clone(), + shortcut, + keys: action_keys(&s.prefs().shortcuts), + alternates: action_keys(&s.prefs().alternate_shortcuts), + recording: None, + }; + let response = view(&draft, &draft.value); + s.actions.take = id; + s.actions.editor = Some(draft); + Ok(response) +} + +/// Inspect the current draft; an explicit id also rejects a stale read. +fn get(s: &mut Session, p: &Value) -> Result { + fields(p, &["draft"], GET)?; + let draft = if p.get("draft").is_some() { owned(s, p, GET)? } else { s.actions.editor.as_ref().ok_or_else(|| bad(GET, "no editor is open"))? }; + Ok(view(draft, &visible(s, draft)?)) +} + +/// Only deliberately edited shortcuts are normalized; imported unrelated strings remain intact. +fn shortcut(text: &str, old: &str) -> Result { + if text == old || text.is_empty() { + return Ok(text.to_string()); + } + let (key, shift, command) = crate::atn_cmds::parse_function_key(text).ok_or_else(|| bad(UPDATE, "shortcut must be empty or F1..F15 with Cmd/Shift"))?; + Ok(crate::atn_cmds::function_key_name(key, shift, command)) +} + +/// Stage current metadata fields; future backend fields remain in the complete clone untouched. +fn update(s: &mut Session, p: &Value) -> Result { + fields(p, &["draft", "name", "color", "shortcut"], UPDATE)?; + not_playing(s, UPDATE)?; + let draft = owned(s, p, UPDATE)?; + let mut next = draft.clone(); + if let Some(value) = p.get("name") { + next.value.name = name(value, Some(&next.value.name), UPDATE)?; + } + if let Some(value) = p.get("color") { + let color = value.as_str().ok_or_else(|| bad(UPDATE, "`color` must be a string"))?; + if color != next.value.color && !super::BUTTON_COLORS.contains(&color) { + return Err(bad(UPDATE, "choose a measured action colour")); + } + next.value.color = color.to_string(); + } + if let Some(value) = p.get("shortcut") { + next.shortcut = shortcut(value.as_str().ok_or_else(|| bad(UPDATE, "`shortcut` must be a string"))?, &next.shortcut)?; + } + let response = view(&next, &visible(s, &next)?); + s.actions.editor = Some(next); + Ok(response) +} + +/// A user-supplied step index, checked before any draft cursor or flag changes. +fn index(p: &Value, key: &str, len: usize, insert: bool) -> Result { + p.get(key) + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .filter(|i| *i < len || (insert && *i == len)) + .ok_or_else(|| bad(STEP, format!("`{key}` is out of range"))) +} + +/// Validate newly authored content, without interpreting or normalizing an untouched opaque step. +fn new_step(p: &Value, old: Option<&(String, Value)>, draft: &Draft) -> Result<(String, Value)> { + let id = match p.get("id") { + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(bad(STEP, "`id` must be a command string")), + None => old.map(|step| step.0.as_str()).ok_or_else(|| bad(STEP, "supply the step `id`"))?, + }; + let params = p.get("params").cloned().or_else(|| old.map(|step| step.1.clone())).unwrap_or_else(|| json!({})); + if old.is_some_and(|step| step.0 == id && step.1 == params) { + return Ok((id.to_string(), params)); + } + if !crate::actions_edit_cmds::is_step_command(id) || !params.is_object() { + return Err(bad(STEP, "a new step needs a known command and object params")); + } + if id == "actions.play" + && (params.get("action").and_then(Value::as_str) == Some(draft.value.name.as_str()) + || draft.source.is_some_and(|source| params.get("action").and_then(Value::as_u64) == Some(source as u64))) + { + return Err(bad(STEP, "an action cannot play itself")); + } + Ok((id.to_string(), params)) +} + +/// Remap actual step flags, retaining malformed legacy indices rather than cleaning them up. +fn remap(value: &mut Action, old_len: usize, f: impl Fn(usize) -> Option) { + value.remap_steps(|i| if i >= old_len { Some(i) } else { f(i) }); +} + +/// Insert or replace after complete validation, retaining all unrelated Action fields. +fn insert_or_replace(value: &mut Action, draft: &Draft, p: &Value, op: &str) -> Result<()> { + let len = value.steps.len(); + let at = index(p, "step", len, op == "insert")?; + let step = new_step(p, if op == "replace" { value.steps.get(at) } else { None }, draft)?; + if op == "insert" { + if len >= MAX_STEPS { + return Err(bad(STEP, "the draft exceeds the 100000-step limit")); + } + value.steps.insert(at, step); + remap(value, len, |i| Some(if i >= at { i + 1 } else { i })); + } else if let Some(original) = value.steps.get_mut(at) { + *original = step; + } + Ok(()) +} + +/// Structural edits copy opaque steps verbatim and keep enabled/dialog flags attached. +fn rearrange(value: &mut Action, p: &Value, op: &str) -> Result<()> { + let len = value.steps.len(); + let at = index(p, "step", len, false)?; + match op { + "delete" => { + value.steps.remove(at); + remap(value, len, |i| if i == at { None } else { Some(if i > at { i - 1 } else { i }) }); + } + "move" => { + let to = index(p, "to", len, false)?; + let step = value.steps.remove(at); + value.steps.insert(to, step); + remap(value, len, |i| Some(super::moved_index(i, at, to))); + } + "duplicate" => { + if len >= MAX_STEPS { + return Err(bad(STEP, "the draft exceeds the 100000-step limit")); + } + let step = value.steps.get(at).cloned().ok_or_else(|| bad(STEP, "step is out of range"))?; + let (disabled, dialog) = (value.disabled.contains(&at), value.dialogs.contains(&at)); + value.steps.insert(at + 1, step); + remap(value, len, |i| Some(if i > at { i + 1 } else { i })); + if disabled { + value.disabled.insert(at + 1); + } + if dialog { + value.dialogs.insert(at + 1); + } + } + _ => return Err(bad(STEP, "unknown structural step operation")), + } + Ok(()) +} + +/// Stage one step operation; even invalid operations leave pending recording untouched. +fn step(s: &mut Session, p: &Value) -> Result { + not_playing(s, STEP)?; + let draft = owned(s, p, STEP)?; + let mut value = visible(s, draft)?; + let op = p.get("op").and_then(Value::as_str).ok_or_else(|| bad(STEP, "supply a step `op`"))?; + fields( + p, + match op { + "insert" | "replace" => &["draft", "op", "step", "id", "params"], + "move" => &["draft", "op", "step", "to"], + "enabled" | "dialog" => &["draft", "op", "step", "enabled"], + _ => &["draft", "op", "step"], + }, + STEP, + )?; + match op { + "insert" | "replace" => insert_or_replace(&mut value, draft, p, op)?, + "delete" | "move" | "duplicate" => rearrange(&mut value, p, op)?, + "enabled" | "dialog" => { + let at = index(p, "step", value.steps.len(), false)?; + let enabled = p.get("enabled").and_then(Value::as_bool).ok_or_else(|| bad(STEP, "`enabled` must be true or false"))?; + let flags = if op == "dialog" { &mut value.dialogs } else { &mut value.disabled }; + if enabled == (op == "dialog") { + flags.insert(at); + } else { + flags.remove(&at); + } + } + _ => return Err(bad(STEP, "op must be insert, replace, delete, move, duplicate, enabled or dialog")), + } + let mut next = draft.clone(); + next.value = value; + if next.recording.is_some() { + next.recording = Some(s.journal.len()); + } + let response = view(&next, &next.value); + s.actions.editor = Some(next); + Ok(response) +} + +/// Start a capture-only take; no placeholder action or preset-store revision is created. +fn record(s: &mut Session, p: &Value) -> Result { + fields(p, &["draft"], RECORD)?; + not_playing(s, RECORD)?; + let mut next = owned(s, p, RECORD)?.clone(); + if s.actions.recording_active() { + return Err(bad(RECORD, "already recording; stop first")); + } + let take = s.actions.take.checked_add(1).ok_or_else(|| bad(RECORD, "recording identities exhausted"))?; + next.recording = Some(s.journal.len()); + let response = view(&next, &next.value); + s.actions.take = take; + s.actions.editor = Some(next); + Ok(response) +} + +/// Flush completed logical commands into the draft only, without publishing it. +pub(super) fn flush(s: &mut Session) -> Result<()> { + let Some(draft) = s.actions.editor.as_ref().filter(|draft| draft.recording.is_some()) else { return Ok(()) }; + let value = visible(s, draft)?; + let mut next = draft.clone(); + next.value = value; + next.recording = Some(s.journal.len()); + s.actions.editor = Some(next); + Ok(()) +} + +/// Stop this editor's take while retaining its unpublished steps and normal document edits. +fn stop(s: &mut Session, p: &Value) -> Result { + fields(p, &["draft"], STOP)?; + not_playing(s, STOP)?; + let draft = owned(s, p, STOP)?; + if draft.recording.is_none() { + return Err(bad(STOP, "this draft is not recording")); + } + let mut next = draft.clone(); + next.value = visible(s, draft)?; + next.recording = None; + let response = view(&next, &next.value); + s.actions.editor = Some(next); + Ok(response) +} + +/// Reject external library/shortcut edits before preparing any definition publication. +fn unchanged(s: &Session, draft: &Draft) -> Result<()> { + let source_changed = draft.source.is_some_and(|index| s.actions.list.get(index) != Some(&draft.opening)); + if s.actions.rev != draft.library_revision + || source_changed + || action_keys(&s.prefs().shortcuts) != draft.keys + || action_keys(&s.prefs().alternate_shortcuts) != draft.alternates + { + return Err(bad(SAVE, "the action library or action shortcuts changed; keep or discard this draft and reopen")); + } + Ok(()) +} + +/// Rewrite only the existing named call fields, sharing Classic Rename's reference rules. +fn rename_calls(params: &mut Value, id: &str, old: &str, new: &str) { + for key in super::call_keys(id) { + if params.get(*key).and_then(Value::as_str) == Some(old) { + params[*key] = json!(new); + } + } +} + +/// Only changed references need prepared copies; no library or Session snapshot is taken. +fn references(s: &Session, draft: &Draft, value: &mut Action) -> Vec<(usize, usize, Value)> { + let mut changes = Vec::new(); + if draft.source.is_none() || draft.opening.name == value.name { + return changes; + } + for (id, params) in &mut value.steps { + rename_calls(params, id, &draft.opening.name, &value.name); + } + for (action, item) in s.actions.list.iter().enumerate().filter(|(i, _)| Some(*i) != draft.source) { + for (step, (id, params)) in item.steps.iter().enumerate() { + if super::call_keys(id).iter().any(|key| params.get(*key).and_then(Value::as_str) == Some(draft.opening.name.as_str())) { + let mut next = params.clone(); + rename_calls(&mut next, id, &draft.opening.name, &value.name); + changes.push((action, step, next)); + } + } + } + changes +} + +/// Prepare only the two binding maps, retaining ordinary menu shortcuts and opaque values. +fn bindings(s: &Session, draft: &Draft, value: &Action) -> Bindings { + let mut keys = s.prefs().shortcuts.clone(); + let mut alternates = s.prefs().alternate_shortcuts.clone(); + let old = format!("{}{}", super::PLAY_SHORTCUT_PREFIX, draft.opening.name); + let new = format!("{}{}", super::PLAY_SHORTCUT_PREFIX, value.name); + if draft.source.is_some() && old != new { + if let Some(key) = keys.remove(&old) { + keys.insert(new.clone(), key); + } + if let Some(key) = alternates.remove(&old) { + alternates.insert(new.clone(), key); + } + } + if draft.shortcut != draft.opening_shortcut { + if !draft.shortcut.is_empty() { + for (id, key) in &mut keys { + if id.starts_with(super::PLAY_SHORTCUT_PREFIX) && id != &new && key == &draft.shortcut { + key.clear(); + } + } + } + keys.insert(new, draft.shortcut.clone()); + } + (keys, alternates) +} + +/// Atomically publish a validated logical definition update, never a sequence of live commands. +fn save(s: &mut Session, p: &Value) -> Result { + fields(p, &["draft"], SAVE)?; + not_playing(s, SAVE)?; + let draft = owned(s, p, SAVE)?; + if s.actions.recording.is_some() { + return Err(bad(SAVE, "stop Classic recording first")); + } + unchanged(s, draft)?; + let mut value = visible(s, draft)?; + if value == draft.opening && draft.shortcut == draft.opening_shortcut { + // Save closes this capture even when it has no completed steps. The committed + // library, shortcut maps and their revisions still remain exactly unchanged. + let mut next = draft.clone(); + next.recording = None; + let response = view(&next, &value); + s.actions.editor = Some(next); + return Ok(response); + } + if (draft.source.is_none() || value.name != draft.opening.name) + && s.actions.list.iter().enumerate().any(|(i, action)| Some(i) != draft.source && action.name == value.name) + { + return Err(bad(SAVE, "an action with this name already exists")); + } + let changes = references(s, draft, &mut value); + let (keys, alternates) = bindings(s, draft, &value); + let target = draft.source.unwrap_or(s.actions.list.len()); + let changed = draft.source.is_none() || value != draft.opening || !changes.is_empty(); + let prefs_changed = keys != s.prefs().shortcuts || alternates != s.prefs().alternate_shortcuts; + let mut next = draft.clone(); + next.value = value.clone(); + next.opening = value.clone(); + next.source = Some(target); + next.library_revision = if changed { s.actions.rev.saturating_add(1) } else { s.actions.rev }; + next.opening_shortcut = next.shortcut.clone(); + next.keys = action_keys(&keys); + next.alternates = action_keys(&alternates); + next.recording = None; + let response = view(&next, &value); + if target == s.actions.list.len() { + s.actions.list.try_reserve(1).map_err(|_| bad(SAVE, "not enough memory for the action"))?; + } + // All validation and copies are complete. No command execution or fallible work follows. + for (action, step, params) in changes { + if let Some((_, old)) = s.actions.list.get_mut(action).and_then(|action| action.steps.get_mut(step)) { + *old = params; + } + } + if target == s.actions.list.len() { + s.actions.list.push(value); + } else if let Some(old) = s.actions.list.get_mut(target) { + *old = value; + } + if changed { + s.actions.touch(); + } + if prefs_changed { + s.edit_prefs(|prefs| { + prefs.shortcuts = keys; + prefs.alternate_shortcuts = alternates; + }); + } + s.actions.editor = Some(next); + Ok(response) +} + +/// Discard only this editor, never the live library, another recorder or the global journal. +fn discard(s: &mut Session, p: &Value) -> Result { + fields(p, &["draft"], DISCARD)?; + not_playing(s, DISCARD)?; + let id = owned(s, p, DISCARD)?.id; + s.actions.editor = None; + Ok(json!({"discarded":id})) +} + +/// Editor commands are metadata operations, not recorded action steps or menu entries. +fn command(id: &'static str, label: &'static str, params: &'static str, run: fn(&mut Session, &Value) -> Result) -> CommandSpec { + CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: super::always, run, journal: false } +} + +/// Expose the bounded backend editor contract through normal engine dispatch. +pub(super) fn specs() -> Vec { + vec![ + command( + OPEN, + "Open Action Draft", + r#"{"action":name|index?} or {"name":str?,"set":str?} → draft view. One unpublished complete Action; untouched New Save is a no-op."#, + open, + ), + command( + GET, + "Get Action Draft", + r#"{"draft":id?} → {draft,action,shortcut,dirty,recording,source,libraryRevision}. Includes completed pending logical steps without publishing."#, + get, + ), + command( + UPDATE, + "Update Action Draft", + r#"{"draft":id,"name":str?,"color":str?,"shortcut":str?} → draft view. Stage only; measured colours or unchanged opaque original; shortcut empty or Cmd/Shift+F1..F15."#, + update, + ), + command( + STEP, + "Edit Action Draft Step", + r#"{"draft":id,"op":"insert"|"replace"|"delete"|"move"|"duplicate"|"enabled"|"dialog","step":index,"id":command?,"params":object?,"to":index?,"enabled":bool?} → draft view. Bounds/flags validated before mutation; opaque unedited steps retained."#, + step, + ), + command( + RECORD, + "Record Action Draft", + r#"{"draft":id} → draft view. Capture normalized logical commands into this draft only; mutually exclusive with Classic recording."#, + record, + ), + command( + STOP, + "Stop Action Draft Recording", + r#"{"draft":id} → draft view. Completed captured steps stay unpublished. Late jobs do not enter an ended or later take."#, + stop, + ), + command( + SAVE, + "Save Action Draft", + r#"{"draft":id} → clean draft view. Logical atomic definition/reference/shortcut commit after opening-state guards; failures retain the draft. Save ends its own capture, even empty; no-op leaves committed definitions/shortcuts/revisions unchanged. Existing preset I/O warning/retry semantics apply."#, + save, + ), + command( + DISCARD, + "Discard Action Draft", + r#"{"draft":id} → {discarded:id}. Drop only its draft/capture; retain document edits, journal, current library and shortcuts."#, + discard, + ), + ] +} diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index f8e4da4f54d..ef13d5ad155 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -30,7 +30,7 @@ fn index(p: &Value, key: &str, cmd: &str) -> Result> { /// Whether `id` can be a step: a registered command, a Play Action call, or one of the desktop /// shell's recorded save/view commands. -fn is_step_command(id: &str) -> bool { +pub(crate) fn is_step_command(id: &str) -> bool { crate::commands::find(id).is_some() || id == "actions.play" || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id) } diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 307043b5925..0971bd0d868 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -3,7 +3,7 @@ use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json} pub(super) fn load(s: &mut Session, p: &Value) -> Result { let cmd = "actions.load"; - if s.actions.recording.is_some() || s.actions.playing > 0 { + if s.actions.recording_active() || s.actions.playing > 0 { return Err(bad(cmd, "stop recording or playing before loading actions")); } let encoding = match p.get("encoding") { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ae1b880a64a..cdd4af99055 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -1241,11 +1241,11 @@ impl Session { self.jobs.spawn = background; // Commands this one runs on its own behalf journal after this mark (#3067). let nested_from = self.journal.len(); - let pre_selected = if self.actions.recording.is_some() { self.active().map(|st| st.selected_layers()) } else { None }; + let pre_selected = if self.actions.recording_active() { self.active().map(|st| st.selected_layers()) } else { None }; // A replayable command is recorded as one step, so recording pauses while it runs: an // action it plays (a script step) must not flush the steps it ran so far into the action // being recorded, nor move the recording mark past the entries it is about to replace. - let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; + let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.take_recording() } else { None }; let before = self.active().map(|st| st.doc.clone()); let docs_before: Vec<_> = self.docs.iter().map(|st| st.doc.id).collect(); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an @@ -1255,9 +1255,7 @@ impl Session { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &run_params))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{id}` failed with an internal error (logged); the document is unchanged")))); self.live_commit = None; - if self.actions.recording.is_none() { - self.actions.recording = recording; - } + self.actions.restore_capture(recording); self.jobs.spawn = false; self.coalesce_request = None; self.color_restrict = None; @@ -1277,7 +1275,7 @@ impl Session { job.command = id.to_string(); job.params = params; job.journal = spec.journal; - job.recorded_by = self.actions.recording.map(|_| self.actions.take); + job.recorded_by = self.actions.recording_active().then_some(self.actions.take); job.pre_selected = pre_selected.clone(); let jid = job.id; self.jobs.running.push(job); @@ -1360,7 +1358,7 @@ impl Session { } if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { let params = match id { - _ if self.actions.recording.is_none() => params, + _ if !self.actions.recording_active() => params, "layer.duplicate" => crate::artboard_cmds::recorded_duplicate(self, crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected)), "layerComp.apply" => crate::comps_cmds::recorded_apply(self, params), _ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected), @@ -1393,7 +1391,7 @@ impl Session { job; // A recording started after the job (#7289) keeps what it has so far, and the job's step // goes into the journal past its start, so that recording doesn't get it. - let foreign = self.actions.recording.is_some() && *recorded_by != Some(self.actions.take); + let foreign = self.actions.recording_active() && *recorded_by != Some(self.actions.take); if foreign { crate::actions_cmds::flush_recording(self)?; } @@ -1431,15 +1429,13 @@ impl Session { // Apply-time hooks can dispatch commands too. Keep the same single-step recording // contract as synchronous dispatch without truncating work done while the worker ran. let nested_from = self.journal.len(); - let recording = if *journal && crate::actions_cmds::replayable(command) { self.actions.recording.take() } else { None }; + let recording = if *journal && crate::actions_cmds::replayable(command) { self.actions.take_recording() } else { None }; let r = match apply { Some(f) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self, payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label}` failed with an internal error (logged); the document is unchanged")))), None => Err(EngineError::Other("internal error: job applied twice".into())), }; - if self.actions.recording.is_none() { - self.actions.recording = recording; - } + self.actions.restore_capture(recording); self.coalesce_request = None; self.color_restrict = None; self.filter_mask_layer = None; @@ -1448,8 +1444,8 @@ impl Session { // to that document instead, when the user switched tabs mid-job. if r.is_ok() { self.after_command(command, params.clone(), *journal, Some(nested_from), pre_selected.as_deref()); - if foreign && let Some((start, _)) = self.actions.recording.as_mut() { - *start = self.journal.len(); + if foreign { + self.actions.advance_recording(self.journal.len()); } } // Give the user back the layers they selected while the job ran, unless the job chose a diff --git a/crates/engine/tests/it/actions_editor_draft.rs b/crates/engine/tests/it/actions_editor_draft.rs new file mode 100644 index 00000000000..6a5cd727f19 --- /dev/null +++ b/crates/engine/tests/it/actions_editor_draft.rs @@ -0,0 +1,297 @@ +//! The editor publishes definitions only at Save; recording still edits the real document. +use photocraft_engine::{ + Session, + jobs::Started, + preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, +}; +use serde_json::{Value, json}; + +/// A real stored library, a named caller and conflicting action/menu shortcut bindings. +fn session() -> (Session, MemBackend) { + let store = MemBackend::default(); + let mut s = Session::new(); + assert!(s.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + s.execute("file.new", json!({"width":40,"height":30,"background":"#804020"})).unwrap(); + s.execute("actions.record", json!({"name":"Original"})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Original layer"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + for name in ["Caller", "Other"] { + s.execute("actions.record", json!({"name":name})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + } + s.execute("actions.insert", json!({"action":"Caller","id":"actions.play","params":{"action":"Original"}})).unwrap(); + s.execute( + "edit.keyboardShortcuts", + json!({"set":{"actions.play:Original":"F6","actions.play:Other":"F7","edit.undo":"F7"},"allowUnknown":true,"removeConflicts":false}), + ) + .unwrap(); + (s, store) +} + +/// Route every mutating request through its own editor identity. +fn editor(s: &mut Session, command: &str, draft: u64, mut params: Value) -> photocraft_engine::Result { + params["draft"] = json!(draft); + s.execute(&format!("actions.editor.{command}"), params) +} + +/// Opening is an actual registered command, not a manually installed fake library row. +fn open(s: &mut Session, params: Value) -> u64 { + s.execute("actions.editor.open", params).unwrap()["draft"].as_u64().unwrap() +} + +/// Metadata, portable recorded targets and named calls stay out of the stored library. +#[test] +fn edits_and_recording_stay_draft_until_save_or_discard() { + let (mut s, store) = session(); + let (list, rev, prefs) = (s.actions.list.clone(), s.actions.rev, s.prefs().clone()); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let before = s.active().unwrap().doc.clone(); + let draft = open(&mut s, json!({"action":"Original"})); + editor(&mut s, "update", draft, json!({"name":"Draft name","color":"teal","shortcut":"F7"})).unwrap(); + editor(&mut s, "record", draft, json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.setProps", json!({"layer":layer.0,"visible":false})).unwrap(); + s.execute("actions.play", json!({"action":"Caller"})).unwrap(); + let shown = editor(&mut s, "get", draft, json!({})).unwrap(); + assert_eq!(shown["action"]["steps"].as_array().unwrap().len(), 3); + assert!(shown["action"]["steps"][1][1].get("_actionLayer").is_some()); + assert!(shown["action"]["steps"][1][1].get("layer").is_none()); + assert_eq!(shown["action"]["steps"][2], json!(["actions.play",{"action":"Caller"}])); + editor(&mut s, "stop", draft, json!({})).unwrap(); + assert_eq!(s.actions.list, list); + assert_eq!(s.actions.rev, rev); + assert_eq!(s.prefs(), &prefs); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + let (after, history, journal) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.entries(), s.journal.clone()); + assert_ne!(after.as_ref(), before.as_ref()); + editor(&mut s, "discard", draft, json!({})).unwrap(); + assert_eq!(s.actions.list, list); + assert_eq!(s.prefs(), &prefs); + assert_eq!(s.journal, journal); + assert_eq!(s.active().unwrap().history.entries(), history); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &after)); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); +} + +/// Save publishes flags, rename references and shortcut reassignment together; repeats do nothing. +#[test] +fn save_commits_one_definition_and_its_dependent_references() { + let (mut s, store) = session(); + s.execute("actions.toggle", json!({"action":"Original","step":0,"enabled":false})).unwrap(); + s.execute("actions.dialog", json!({"action":"Original","step":0,"enabled":true})).unwrap(); + // Match Classic assignment: removeConflicts:false does not clear or reject alternates. + s.edit_prefs(|prefs| { + prefs.alternate_shortcuts.insert("actions.play:Other".into(), vec!["F7".into()]); + }); + let draft = open(&mut s, json!({"action":"Original"})); + let (rev, before, history, journal) = (s.actions.rev, s.active().unwrap().doc.clone(), s.active().unwrap().history.entries(), s.journal.clone()); + editor(&mut s, "update", draft, json!({"name":"Renamed","color":"purple","shortcut":"F7"})).unwrap(); + editor(&mut s, "step", draft, json!({"op":"duplicate","step":0})).unwrap(); + editor(&mut s, "step", draft, json!({"op":"move","step":1,"to":0})).unwrap(); + editor(&mut s, "step", draft, json!({"op":"replace","step":1,"id":"layer.renameLayer","params":{"name":"Later"}})).unwrap(); + editor(&mut s, "step", draft, json!({"op":"enabled","step":0,"enabled":true})).unwrap(); + let saved = editor(&mut s, "save", draft, json!({})).unwrap(); + assert_eq!(saved["dirty"], false); + assert_eq!(s.actions.rev, rev + 1); + assert_eq!(s.actions.list[0].name, "Renamed"); + assert_eq!(s.actions.list[0].color, "purple"); + assert_eq!(s.actions.list[0].steps.len(), 2); + assert_eq!(s.actions.list[0].disabled.iter().copied().collect::>(), [1]); + assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0, 1]); + assert_eq!(s.actions.list[1].steps[0], ("actions.play".into(), json!({"action":"Renamed"}))); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Original")); + assert_eq!(s.prefs().shortcuts["actions.play:Renamed"], "F7"); + assert_eq!(s.prefs().shortcuts["actions.play:Other"], ""); + assert_eq!(s.prefs().shortcuts["edit.undo"], "F7"); + assert_eq!(s.prefs().alternate_shortcuts["actions.play:Other"], ["F7"]); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + let (state, prefs, bytes) = (s.actions.clone(), s.prefs().clone(), store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap()); + editor(&mut s, "save", draft, json!({})).unwrap(); + assert_eq!(s.actions, state); + assert_eq!(s.prefs(), &prefs); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); +} + +/// Invalid edits and Save collisions retain both the draft and every committed component. +#[test] +fn malformed_requests_and_collisions_fail_without_partial_save() { + let (mut s, store) = session(); + let draft = open(&mut s, json!({"action":"Original"})); + let original = s.actions.clone(); + for params in [json!({"shortcut":"not a key"}), json!({"color":"@atn-color:65535"}), json!({"name":5}), json!({"description":"not implemented"})] { + assert!(editor(&mut s, "update", draft, params).is_err()); + assert_eq!(s.actions, original); + } + for params in [json!({"op":"delete","step":u64::MAX}), json!({"op":"replace","step":0,"id":5}), json!({"op":"insert","step":0,"id":"no.such.command"})] { + assert!(editor(&mut s, "step", draft, params).is_err()); + assert_eq!(s.actions, original); + } + editor(&mut s, "update", draft, json!({"name":"Other","shortcut":"F7"})).unwrap(); + let (state, prefs, bytes, journal) = (s.actions.clone(), s.prefs().clone(), store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), s.journal.clone()); + assert!(editor(&mut s, "save", draft, json!({})).is_err()); + assert_eq!(s.actions, state); + assert_eq!(s.prefs(), &prefs); + assert_eq!(s.journal, journal); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); +} + +/// A stale library index or shortcut namespace never retargets Save or rolls back a foreign edit. +#[test] +fn stale_save_and_discard_preserve_external_library_or_shortcut_edits() { + for shortcut_only in [false, true] { + let (mut s, store) = session(); + let draft = open(&mut s, json!({"action":"Original"})); + editor(&mut s, "update", draft, json!({"name":"Draft name"})).unwrap(); + if shortcut_only { + s.execute("edit.keyboardShortcuts", json!({"set":{"actions.play:Original":"F9"},"allowUnknown":true})).unwrap(); + } else { + s.execute("actions.move", json!({"action":"Original","to":2})).unwrap(); + } + let (list, rev, prefs, bytes) = (s.actions.list.clone(), s.actions.rev, s.prefs().clone(), store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap()); + assert!(editor(&mut s, "save", draft, json!({})).is_err()); + assert_eq!(editor(&mut s, "get", draft, json!({})).unwrap()["action"]["name"], "Draft name"); + editor(&mut s, "discard", draft, json!({})).unwrap(); + assert_eq!(s.actions.list, list); + assert_eq!(s.actions.rev, rev); + assert_eq!(s.prefs(), &prefs); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + } +} + +/// Unrelated edits preserve malformed flags, opaque steps, unknown colours and old shortcuts. +#[test] +fn complete_action_clone_retains_unedited_opaque_original_data() { + let (mut s, _) = session(); + s.actions.list[0].color = "@atn-color:65535".into(); + s.actions.list[0].steps.push(("unknown.legacy.step".into(), json!(["opaque", 9]))); + s.actions.list[0].disabled.insert(999); + s.actions.list[0].dialogs.insert(999); + s.execute("actions.rename", json!({"action":0,"name":"Legacy"})).unwrap(); + s.edit_prefs(|prefs| { + prefs.shortcuts.insert("actions.play:Legacy".into(), "unparsed original binding".into()); + }); + let mut expected = s.actions.list[0].clone(); + let draft = open(&mut s, json!({"action":0})); + editor(&mut s, "update", draft, json!({"name":"Renamed"})).unwrap(); + editor(&mut s, "save", draft, json!({})).unwrap(); + expected.name = "Renamed".into(); + assert_eq!(s.actions.list[0], expected); + assert_eq!(s.prefs().shortcuts["actions.play:Renamed"], "unparsed original binding"); +} + +/// A default new draft has no library row, and stale editor handles cannot change a later one. +#[test] +fn untouched_new_save_is_noop_and_old_editor_owner_is_rejected() { + let (mut s, store) = session(); + let list = s.actions.list.clone(); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let draft = open(&mut s, json!({})); + let state = s.actions.clone(); + assert_eq!(editor(&mut s, "save", draft, json!({})).unwrap()["dirty"], false); + assert_eq!(s.actions, state); + assert_eq!(s.actions.list, list); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + editor(&mut s, "record", draft, json!({})).unwrap(); + let (rev, prefs, take) = (s.actions.rev, s.prefs().clone(), s.actions.take); + let empty = editor(&mut s, "save", draft, json!({})).unwrap(); + assert_eq!(empty["dirty"], false); + assert_eq!(empty["recording"], false); + assert_eq!(s.actions.list, list); + assert_eq!(s.actions.rev, rev); + assert_eq!(s.actions.take, take); + assert_eq!(s.prefs(), &prefs); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + editor(&mut s, "discard", draft, json!({})).unwrap(); + let next = open(&mut s, json!({})); + let current = s.actions.clone(); + for command in ["update", "record", "stop", "save", "discard"] { + assert!(editor(&mut s, command, draft, json!({})).is_err()); + assert_eq!(s.actions, current); + } + editor(&mut s, "step", next, json!({"op":"insert","step":0,"id":"edit.fill","params":{"color":"#00ff00"}})).unwrap(); + editor(&mut s, "save", next, json!({})).unwrap(); + assert_eq!(s.actions.list.len(), list.len() + 1); + assert_eq!(s.actions.list.last().unwrap().steps.len(), 1); +} + +/// The existing immediate Classic recorder still creates its row and flushes at Stop. +#[test] +fn classic_recording_remains_immediate_without_editor_save() { + let (mut s, store) = session(); + let len = s.actions.list.len(); + s.execute("actions.record", json!({"name":"Classic"})).unwrap(); + assert_eq!(s.actions.list.len(), len + 1); + let native: Value = serde_json::from_slice(&store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap()).unwrap(); + assert_eq!(native["actions"].as_array().unwrap().len(), len + 1); + assert!(s.execute("actions.editor.open", json!({})).is_err()); + s.execute("edit.fill", json!({"color":"#00ff00"})).unwrap(); + assert_eq!(photocraft_engine::actions_cmds::pending_steps(&s, len).count(), 1); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[len].steps.len(), 1); + assert_eq!(s.actions.list[len].steps[0].0, "edit.fill"); + assert_eq!(s.actions.list[len].steps[0].1["color"], "#00ff00"); +} + +/// Failed Save keeps an active take; successful Save consumes its pending steps only once. +#[test] +fn save_finishes_its_own_capture_only_after_complete_validation() { + let (mut s, store) = session(); + let draft = open(&mut s, json!({"name":"Captured"})); + editor(&mut s, "record", draft, json!({})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Recorded name"})).unwrap(); + editor(&mut s, "update", draft, json!({"name":"Other"})).unwrap(); + let (state, bytes) = (s.actions.clone(), store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap()); + assert!(editor(&mut s, "save", draft, json!({})).is_err()); + assert_eq!(s.actions, state); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + editor(&mut s, "update", draft, json!({"name":"Captured"})).unwrap(); + let (doc, history, journal) = (s.active().unwrap().doc.clone(), s.active().unwrap().history.entries(), s.journal.clone()); + let saved = editor(&mut s, "save", draft, json!({})).unwrap(); + assert_eq!(saved["recording"], false); + assert_eq!(saved["dirty"], false); + assert_eq!(s.actions.list.len(), 4); + assert_eq!(s.actions.list[3].steps.len(), 1); + assert_eq!(s.actions.list[3].steps[0].0, "layer.renameLayer"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); +} + +/// A late job from a discarded take is journaled, but never enters the next draft capture. +#[test] +fn late_job_cannot_enter_a_discarded_or_later_draft() { + let (mut s, _) = session(); + // The existing job-control fixture: a textured surface ensures real blur work. + s.edit("pattern", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let mut pixels = Vec::new(); + for y in 0..30 { + for x in 0..40 { + let value = ((x * 7 + y * 13) % 31) as f32 / 30.0; + pixels.extend_from_slice(&[value, 1.0 - value, 0.5, 1.0]); + } + } + surface.write_region(photocraft_geom::Rect::new(0, 0, 40, 30), &pixels); + Ok(()) + }) + .unwrap(); + let old = open(&mut s, json!({"name":"Old draft"})); + editor(&mut s, "record", old, json!({})).unwrap(); + let job = match s.start("filter.blur.gaussianBlur", json!({"radius":1})).unwrap() { + Started::Job(id) => id, + _ => panic!("blur must use its real job path"), + }; + editor(&mut s, "discard", old, json!({})).unwrap(); + let next = open(&mut s, json!({"name":"Next draft"})); + editor(&mut s, "record", next, json!({})).unwrap(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + s.wait_job(job).unwrap(); + s.execute("image.adjustments.desaturate", json!({})).unwrap(); + let value = editor(&mut s, "stop", next, json!({})).unwrap(); + let ids: Vec<_> = value["action"]["steps"].as_array().unwrap().iter().map(|step| step[0].as_str().unwrap()).collect(); + assert_eq!(ids, ["file.new", "image.adjustments.desaturate"]); + assert_eq!(s.actions.list.len(), 3); + assert!(s.journal.iter().any(|(id, _)| id == "filter.blur.gaussianBlur")); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index c5fe4e7569f..7a2695e0535 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod actions_editor_draft; mod affinity; mod artboard_export_names; mod atn_action_colors; diff --git a/docs/atn.md b/docs/atn.md index 12860ed3d06..4bf73ec6440 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -34,6 +34,44 @@ It survives library reloads and ATN export, but cannot be selected with indices use a neutral theme card in Classic Button Mode until their swatches are provided by the newer editor. This header support does not parse an action sidecar. +## Unpublished action editor backend + +The backend editor commands keep one complete action draft outside the live library: + +- `actions.editor.open {action}` copies an existing action. With no `action`, it + prepares a new value with an optional `name` and `set`, without creating a row. + It returns a `draft` identity; subsequent mutations must supply that identity. +- `actions.editor.get {draft?}` reports the complete action, proposed shortcut, + dirty/recording state and opening library revision. `update {draft,name?,color?,shortcut?}` + stages current metadata. Description/icon fields and sidecar handling are a later phase; + unsupported fields fail explicitly rather than being silently lost. +- `actions.editor.step {draft,op,step,...}` stages insert, replace, delete, move, + duplicate, enabled and dialog operations. Moved flags follow their steps. Opaque + unedited original steps, flags and imported colours are retained. +- `actions.editor.record` / `stop` capture normalized logical commands into the + draft, including named calls and job-take ownership, without publishing a placeholder. + Completed pending steps are visible immediately. Classic `actions.record` / `stop` + retain their existing immediate-library behavior and require no editor Save. +- `actions.editor.save {draft}` validates the opening action/revision and action + shortcut namespace before one logical definition/reference/shortcut update. It + ends its own capture when publishing. Collisions and stale state leave the draft + and committed components intact. Save ends an empty capture too, while leaving + unchanged committed definitions, shortcuts and revisions alone; an untouched + default new draft remains unpublished. Shortcut conflicts follow Classic assignment, + including its `removeConflicts:false` behavior for alternates. The preset store keeps its existing I/O + warning/retry behavior, not a new multi-file durable transaction. +- `actions.editor.discard {draft}` drops only that draft and capture. It does not + restore an old library snapshot, erase the journal or undo document edits made + while recording. Old editor identities cannot mutate a reopened editor. + +This is the backend increment for #10946, not the completed editor page. The UI's +Save/Discard/Cancel confirmation policy and its suspended-recording adapter remain +separate work. In particular, the legacy shell playback continuation carries a +library index, so direct shell playback refuses an active draft recording until +the editor UI owns that continuation. Headless `Session::execute(actions.play)` +already suspends/restores either recorder and captures one named call. No #10766 +or #10965 implementation is implicitly imported by this backend. + ## Supported events (144) | Code | String ID | Label | PhotoCraft command(s) | From 042274112b8ac3ca3d47da97d3123f5b3657ea99 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 05:49:26 -0800 Subject: [PATCH 078/384] Preserve action descriptions and icon identifiers in editor drafts --- crates/engine/src/cmd/actions_cmds.rs | 23 ++- crates/engine/src/cmd/actions_cmds/editor.rs | 11 +- crates/engine/src/cmd/atn_cmds/convert.rs | 5 + crates/engine/tests/it/action_metadata.rs | 161 ++++++++++++++++++ .../engine/tests/it/actions_editor_draft.rs | 2 +- crates/engine/tests/it/main.rs | 1 + docs/atn.md | 13 +- 7 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/it/action_metadata.rs diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index f674f22f545..94d7528204a 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -39,6 +39,12 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Action { pub name: String, + /// Free-form editor description. Older native action libraries have no description. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, + /// Raw editor glyph identifier; empty uses the default glyph. Keep unknown identifiers verbatim. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub icon: String, #[serde(default)] pub steps: Vec<(String, Value)>, /// Indices of steps excluded from playback (unchecked in the Actions panel). @@ -83,7 +89,16 @@ pub(crate) fn color_to_atn(color: &str) -> Option { impl Action { pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { - Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None, color: String::new() } + Self { + name: name.into(), + description: String::new(), + icon: String::new(), + steps, + disabled: BTreeSet::new(), + dialogs: BTreeSet::new(), + set: None, + color: String::new(), + } } /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. @@ -429,7 +444,9 @@ fn get(s: &mut Session, p: &Value) -> Result { let idx = resolve(&s.actions.list, p, "actions.get")?; let action = &s.actions.list[idx]; let steps: Vec = action.steps.iter().map(|(id, params)| json!([id, params])).collect(); - Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set, "color": action.color})) + Ok( + json!({"name": action.name, "description": action.description, "icon": action.icon, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set, "color": action.color}), + ) } fn record(s: &mut Session, p: &Value) -> Result { @@ -887,7 +904,7 @@ pub fn specs() -> Vec { label: "Get Action", menu: &[], shortcut: None, - params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is a measured action swatch, empty, or reserved @atn-color:N metadata preserving an unknown imported u16 index)"##, + params: r##"{"action":name|index} → {name, description:str, icon:str, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null, color:str} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions; color is a measured action swatch, empty, or reserved @atn-color:N metadata preserving an unknown imported u16 index)"##, enabled: always, run: get, journal: false, diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index b85b68504a9..7e1ef82875c 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -160,13 +160,20 @@ fn shortcut(text: &str, old: &str) -> Result { /// Stage current metadata fields; future backend fields remain in the complete clone untouched. fn update(s: &mut Session, p: &Value) -> Result { - fields(p, &["draft", "name", "color", "shortcut"], UPDATE)?; + fields(p, &["draft", "name", "description", "icon", "color", "shortcut"], UPDATE)?; not_playing(s, UPDATE)?; let draft = owned(s, p, UPDATE)?; let mut next = draft.clone(); if let Some(value) = p.get("name") { next.value.name = name(value, Some(&next.value.name), UPDATE)?; } + if let Some(value) = p.get("description") { + next.value.description = value.as_str().ok_or_else(|| bad(UPDATE, "`description` must be a string"))?.to_string(); + } + if let Some(value) = p.get("icon") { + // An unknown identifier is retained metadata; the picker can use a neutral display fallback. + next.value.icon = value.as_str().ok_or_else(|| bad(UPDATE, "`icon` must be a string"))?.to_string(); + } if let Some(value) = p.get("color") { let color = value.as_str().ok_or_else(|| bad(UPDATE, "`color` must be a string"))?; if color != next.value.color && !super::BUTTON_COLORS.contains(&color) { @@ -523,7 +530,7 @@ pub(super) fn specs() -> Vec { command( UPDATE, "Update Action Draft", - r#"{"draft":id,"name":str?,"color":str?,"shortcut":str?} → draft view. Stage only; measured colours or unchanged opaque original; shortcut empty or Cmd/Shift+F1..F15."#, + r#"{"draft":id,"name":str?,"description":str?,"icon":str?,"color":str?,"shortcut":str?} → draft view. Stage only; preserve description and raw icon text, including unknown identifiers. Measured colours or unchanged opaque original; shortcut empty or Cmd/Shift+F1..F15."#, update, ), command( diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 71d00c11595..57ee26a2e52 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -228,6 +228,11 @@ pub fn export( let mut skipped = Vec::new(); let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new(), ..Default::default() }; for a in actions { + // Until sidecar writing is implemented, never silently discard editor metadata. + // skipUnsupported applies to steps, not the action's description or icon. + if !a.description.is_empty() || !a.icon.is_empty() { + return Err(format!("action `{}` has editor metadata that requires ATN sidecar support", a.name)); + } let color = color_to_atn(&a.color).ok_or_else(|| format!("action `{}` has an invalid colour", a.name))?; let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, color, ..Default::default() }; if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { diff --git a/crates/engine/tests/it/action_metadata.rs b/crates/engine/tests/it/action_metadata.rs new file mode 100644 index 00000000000..26541ed288a --- /dev/null +++ b/crates/engine/tests/it/action_metadata.rs @@ -0,0 +1,161 @@ +//! Native action-library metadata defaults and preservation, independent of ATN sidecars. +use photocraft_engine::{ + Session, + actions_cmds::{Action, ActionsFile}, + preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, +}; +use serde_json::json; + +const DESCRIPTION: &str = " First line\nCafé 🌿\0 "; +const UNKNOWN_ICON: &str = " future/editor-icon-图标 "; + +fn described(name: &str, icon: &str) -> Action { + let mut action = Action::new(name, vec![("future.step".into(), json!({"raw": [0, 255], "nested": {"keep": true}}))]); + action.description = DESCRIPTION.into(); + action.icon = icon.into(); + action.set = Some("Metadata set".into()); + action.color = "@atn-color:65535".into(); + action.disabled.insert(0); + action.dialogs.insert(0); + action +} + +#[test] +fn old_native_library_defaults_metadata_without_rewriting_on_attach() { + let legacy = br#"{"version":1,"actions":[{"name":"Legacy","steps":[["future.step",{"raw":[0,255]}]]}]}"#; + let mem = MemBackend::default(); + mem.write(ACTIONS_FILE, legacy).unwrap(); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + let action = &session.actions.list[0]; + assert!(action.description.is_empty()); + assert!(action.icon.is_empty()); + assert_eq!(*action, Action::new("Legacy", vec![("future.step".into(), json!({"raw": [0, 255]}))])); + assert_eq!(serde_json::to_value(action).unwrap(), json!({"name": "Legacy", "steps": [["future.step", {"raw": [0, 255]}]]})); + assert!(Action::default().description.is_empty()); + assert!(Action::default().icon.is_empty()); + session.sync_preset_store(); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap().as_slice(), legacy); +} + +#[test] +fn native_json_preserves_descriptions_and_known_or_unknown_icon_identifiers() { + for icon in ["ClickAction", UNKNOWN_ICON] { + let original = described("Described", icon); + let file = ActionsFile { version: 1, actions: vec![original.clone()] }; + let bytes = serde_json::to_vec_pretty(&file).unwrap(); + let stored: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(stored["version"], 1); + assert_eq!(stored["actions"][0]["description"], DESCRIPTION); + assert_eq!(stored["actions"][0]["icon"], icon); + let reopened: ActionsFile = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(reopened, file); + assert_eq!(reopened.actions[0].description, DESCRIPTION); + assert_eq!(reopened.actions[0].icon, icon); + assert_eq!(original.description, DESCRIPTION); + assert_eq!(original.icon, icon); + } +} + +#[test] +fn cloned_actions_keep_metadata_through_rename_and_real_preset_reload() { + let initial = ActionsFile { version: 1, actions: vec![described("Known", "ClickAction"), described("Unknown", UNKNOWN_ICON)] }; + let mem = MemBackend::default(); + mem.write(ACTIONS_FILE, &serde_json::to_vec_pretty(&initial).unwrap()).unwrap(); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + assert_eq!(session.actions.list, initial.actions); + // This source has no actions.duplicate command. Exercise model duplication without claiming one. + let mut copy = session.actions.list[1].clone(); + copy.name = "Copy".into(); + assert_eq!(copy.description, DESCRIPTION); + assert_eq!(copy.icon, UNKNOWN_ICON); + session.actions.list.push(copy); + for (old, new) in [("Known", "Renamed known"), ("Unknown", "Renamed unknown"), ("Copy", "Renamed copy")] { + session.execute("actions.rename", json!({"action": old, "name": new})).unwrap(); + } + for (action, icon) in session.actions.list.iter().zip(["ClickAction", UNKNOWN_ICON, UNKNOWN_ICON]) { + assert_eq!(action.description, DESCRIPTION); + assert_eq!(action.icon, icon); + assert_eq!(action.steps, initial.actions[0].steps); + assert_eq!(action.disabled, initial.actions[0].disabled); + assert_eq!(action.dialogs, initial.actions[0].dialogs); + assert_eq!(action.set, initial.actions[0].set); + assert_eq!(action.color, initial.actions[0].color); + } + session.sync_preset_store(); + let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let saved: ActionsFile = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved.version, 1); + assert_eq!(saved.actions, session.actions.list); + assert_eq!(saved.actions.iter().map(|action| action.name.as_str()).collect::>(), ["Renamed known", "Renamed unknown", "Renamed copy"]); + let mut reopened = Session::new(); + assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + assert_eq!(reopened.actions.list, saved.actions); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); +} + +/// The combined editor API stages metadata privately and publishes it only through Save. +#[test] +fn editor_metadata_stays_draft_until_save_and_discard_keeps_saved_values() { + for icon in ["ClickAction", UNKNOWN_ICON] { + let mut original = Action::new("Editable", Vec::new()); + let initial = ActionsFile { version: 1, actions: vec![original.clone()] }; + let mem = MemBackend::default(); + mem.write(ACTIONS_FILE, &serde_json::to_vec_pretty(&initial).unwrap()).unwrap(); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + let committed = session.actions.list.clone(); + let original_bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let opened = session.execute("actions.editor.open", json!({"action": "Editable"})).unwrap(); + let draft = opened["draft"].as_u64().unwrap(); + let staged = session.execute("actions.editor.update", json!({"draft": draft, "description": DESCRIPTION, "icon": icon})).unwrap(); + assert_eq!(staged["action"]["description"], DESCRIPTION); + assert_eq!(staged["action"]["icon"], icon); + assert_eq!(staged["dirty"], true); + assert_eq!(session.execute("actions.editor.get", json!({"draft": draft})).unwrap(), staged); + assert_eq!(session.actions.list, committed); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), original_bytes); + for invalid in [ + json!({"draft": draft, "description": false, "icon": "Must not replace the icon"}), + json!({"draft": draft, "description": "Must not replace the description", "icon": 7}), + ] { + assert!(session.execute("actions.editor.update", invalid).is_err()); + assert_eq!(session.execute("actions.editor.get", json!({"draft": draft})).unwrap(), staged); + assert_eq!(session.actions.list, committed); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), original_bytes); + } + let saved = session.execute("actions.editor.save", json!({"draft": draft})).unwrap(); + assert_eq!(saved["dirty"], false); + original.description = DESCRIPTION.into(); + original.icon = icon.into(); + assert_eq!(session.actions.list, vec![original.clone()]); + let classic = session.execute("actions.get", json!({"action": "Editable"})).unwrap(); + assert_eq!(classic["description"], DESCRIPTION); + assert_eq!(classic["icon"], icon); + session.sync_preset_store(); + let saved_bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let native: ActionsFile = serde_json::from_slice(&saved_bytes).unwrap(); + assert_eq!(native.actions, vec![original.clone()]); + let mut reopened = Session::new(); + assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + assert_eq!(reopened.actions.list, native.actions); + // Before sidecar writing exists, export must refuse metadata rather than silently lose it. + let committed = session.actions.list.clone(); + let mut plain = original.clone(); + plain.description.clear(); + plain.icon.clear(); + for skip in [false, true] { + assert!(session.execute("actions.save", json!({"action": "Editable", "set": "Metadata set", "skipUnsupported": skip})).is_err()); + let (bytes, skipped) = photocraft_engine::atn_cmds::export("Metadata set", &[&plain], &[], skip).unwrap(); + assert!(!bytes.is_empty()); + assert!(skipped.is_empty()); + assert_eq!(session.actions.list, committed); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), saved_bytes); + } + session.execute("actions.editor.update", json!({"draft": draft, "description": "Discard me", "icon": "UncommittedIcon"})).unwrap(); + session.execute("actions.editor.discard", json!({"draft": draft})).unwrap(); + assert_eq!(session.actions.list, vec![original]); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), saved_bytes); + } +} diff --git a/crates/engine/tests/it/actions_editor_draft.rs b/crates/engine/tests/it/actions_editor_draft.rs index 6a5cd727f19..3ff4b383f3e 100644 --- a/crates/engine/tests/it/actions_editor_draft.rs +++ b/crates/engine/tests/it/actions_editor_draft.rs @@ -120,7 +120,7 @@ fn malformed_requests_and_collisions_fail_without_partial_save() { let (mut s, store) = session(); let draft = open(&mut s, json!({"action":"Original"})); let original = s.actions.clone(); - for params in [json!({"shortcut":"not a key"}), json!({"color":"@atn-color:65535"}), json!({"name":5}), json!({"description":"not implemented"})] { + for params in [json!({"shortcut":"not a key"}), json!({"color":"@atn-color:65535"}), json!({"name":5}), json!({"futureMetadata":"not implemented"})] { assert!(editor(&mut s, "update", draft, params).is_err()); assert_eq!(s.actions, original); } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7a2695e0535..09ebe260fd4 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod action_metadata; mod actions_editor_draft; mod affinity; mod artboard_export_names; diff --git a/docs/atn.md b/docs/atn.md index 4bf73ec6440..8ffb9f9854f 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -42,9 +42,10 @@ The backend editor commands keep one complete action draft outside the live libr prepares a new value with an optional `name` and `set`, without creating a row. It returns a `draft` identity; subsequent mutations must supply that identity. - `actions.editor.get {draft?}` reports the complete action, proposed shortcut, - dirty/recording state and opening library revision. `update {draft,name?,color?,shortcut?}` - stages current metadata. Description/icon fields and sidecar handling are a later phase; - unsupported fields fail explicitly rather than being silently lost. + dirty/recording state and opening library revision. `update {draft,name?,description?,icon?,color?,shortcut?}` + stages metadata without publishing it. Description and raw icon text are retained, + including unknown icon identifiers; non-string values fail without changing the draft. + Native Actions libraries default both fields to empty when loading older files. - `actions.editor.step {draft,op,step,...}` stages insert, replace, delete, move, duplicate, enabled and dialog operations. Moved flags follow their steps. Opaque unedited original steps, flags and imported colours are retained. @@ -64,6 +65,12 @@ The backend editor commands keep one complete action draft outside the live libr restore an old library snapshot, erase the journal or undo document edits made while recording. Old editor identities cannot mutate a reopened editor. +Descriptions and icons currently persist in the native Actions library. ATN sidecar +writing remains unimplemented, so ATN export refuses actions with a nonempty description +or icon even when `skipUnsupported` is set; that option only permits omitting unsupported +steps. Classic actions without this metadata retain their existing export behavior. +The separate `.pcraft` document attachment requirement remains unresolved. + This is the backend increment for #10946, not the completed editor page. The UI's Save/Discard/Cancel confirmation policy and its suspended-recording adapter remain separate work. In particular, the legacy shell playback continuation carries a From 0247c23b4319d75fe91a5ac16c9599f6fd73453d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 07:03:38 -0800 Subject: [PATCH 079/384] Preserve draft recording and permissions across action previews --- crates/engine/src/cmd/actions_cmds.rs | 8 +- crates/engine/src/cmd/actions_cmds/editor.rs | 10 + .../engine/src/cmd/actions_cmds/playback.rs | 153 +++++++ crates/engine/src/jobs.rs | 2 + crates/engine/src/session/execute.rs | 1 + .../engine/tests/it/actions_owned_preview.rs | 127 ++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/dialogs.rs | 5 + crates/ui-egui/src/dialogs/filter_dialog.rs | 18 +- crates/ui-egui/src/panels/actions.rs | 411 ++++++++++++------ .../src/panels/actions/owned_preview_tests.rs | 280 ++++++++++++ docs/atn.md | 24 +- 12 files changed, 881 insertions(+), 159 deletions(-) create mode 100644 crates/engine/src/cmd/actions_cmds/playback.rs create mode 100644 crates/engine/tests/it/actions_owned_preview.rs create mode 100644 crates/ui-egui/src/panels/actions/owned_preview_tests.rs diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 94d7528204a..a4bf85c0dea 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -23,6 +23,7 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; pub mod editor; +pub mod playback; /// A command temporarily owns the recorder while its nested commands run. pub(crate) enum Recording { @@ -134,6 +135,11 @@ pub struct ActionState { /// An unpublished editor value. Neither it nor its capture is part of actions.json. #[serde(skip)] pub editor: Option, + /// The shell owns a capture/preview across a step dialog. The opaque lease releases it. + #[serde(skip)] + pub held_playback: Option, + #[serde(skip)] + pub playback_nonce: u64, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -662,7 +668,7 @@ fn play(s: &mut Session, p: &Value) -> Result { } pub(crate) fn not_playing(s: &Session, cmd: &str) -> Result<()> { - if s.actions.playing > 0 { Err(bad(cmd, "cannot edit actions during playback")) } else { Ok(()) } + if s.actions.playing > 0 || s.actions.held_playback.is_some() { Err(bad(cmd, "cannot edit actions during playback")) } else { Ok(()) } } /// Move to a final zero-based index, either in an action's steps or in the action list. diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index 7e1ef82875c..b629e4032a0 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -67,6 +67,16 @@ fn visible(s: &Session, draft: &Draft) -> Result { Ok(value) } +/// Complete owned preview input, including completed steps not yet flushed from capture. +pub(super) fn preview_value(s: &Session, id: u64) -> Result { + let draft = s.actions.editor.as_ref().filter(|draft| draft.id == id).ok_or_else(|| bad(GET, "this editor is no longer open"))?; + visible(s, draft) +} + +pub(super) fn preview_matches(s: &Session, id: u64, snapshot: &Action) -> bool { + s.actions.editor.as_ref().is_some_and(|draft| draft.id == id && &draft.value == snapshot) +} + /// Structured inspection serializes the complete Action, not a metadata whitelist. fn view(draft: &Draft, value: &Action) -> Value { json!({"draft":draft.id, "action":value, "shortcut":draft.shortcut, diff --git a/crates/engine/src/cmd/actions_cmds/playback.rs b/crates/engine/src/cmd/actions_cmds/playback.rs new file mode 100644 index 00000000000..d4a276471d5 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/playback.rs @@ -0,0 +1,153 @@ +//! Shell capture ownership while an immutable action preview waits for step dialogs. +use std::sync::Arc; + +use super::{Action, Recording, Result, Session, Value, bad}; + +#[derive(Debug)] +enum Destination { + Classic(Action), + Draft(u64), +} + +/// Move-only ownership of a suspended recorder or an unpublished preview. Dropping a stale +/// dialog token cannot target a later recorder; the shell explicitly finishes every exit. +#[derive(Debug)] +pub struct Owner { + serial: u64, + take: u64, + destination: Option, + preview: Option<(u64, Arc)>, + call: Option<(String, usize)>, +} + +fn classic_index(s: &Session, action: &Action) -> Option { + let mut matches = s.actions.list.iter().enumerate().filter(|(_, candidate)| *candidate == action).map(|(index, _)| index); + let index = matches.next()?; + matches.next().is_none().then_some(index) +} + +/// Definition edits cannot invalidate a shell-owned capture, even through direct Session calls. +pub fn admit(s: &Session, id: &str) -> Result<()> { + if s.actions.held_playback.is_some() + && s.actions.playing == 0 + && id.starts_with("actions.") + && !matches!(id, "actions.list" | "actions.get" | "actions.editor.get" | "actions.save") + { + return Err(bad(id, "confirm or cancel the open action step dialog first")); + } + Ok(()) +} + +fn suspend(s: &mut Session, preview: Option<(u64, Arc)>, call: Option<(String, usize)>) -> Result> { + if s.actions.held_playback.is_some() { + return Err(bad("actions.play", "another action owns a suspended playback")); + } + if preview.is_none() && !s.actions.recording_active() { + return Ok(None); + } + // A job started in the old capture must land before its cursor is moved past preview work. + // Jobs started by the paused step have no recorder and retain the normal foreign-take guard. + if s.has_jobs() { + return Err(bad("actions.play", "finish the running jobs before previewing or suspending capture")); + } + let serial = s.actions.playback_nonce.checked_add(1).ok_or_else(|| bad("actions.play", "playback identities exhausted"))?; + super::flush_recording(s)?; + let destination = match s.actions.take_recording() { + Some(Recording::Classic(_, index)) => { + // Valid Classic capture always names a current action. A missing destination must + // fail before acquiring a lease rather than resume into an unrelated row. + match s.actions.list.get(index).cloned() { + Some(action) => Some(Destination::Classic(action)), + None => { + s.actions.recording = Some((s.journal.len(), index)); + return Err(bad("actions.play", "the recording destination no longer exists")); + } + } + } + Some(Recording::Draft { owner, .. }) => Some(Destination::Draft(owner)), + None => None, + }; + s.actions.playback_nonce = serial; + s.actions.held_playback = Some(serial); + Ok(Some(Owner { serial, take: s.actions.take, destination, preview, call })) +} + +/// Resolve an ordinary named library call and suspend either recording destination once. +pub fn library(s: &mut Session, params: &Value) -> Result<(Arc, usize, Option)> { + admit(s, "actions.play")?; + let (action, from) = super::playback_plan(s, params)?; + let owner = suspend(s, None, Some((action.name.clone(), from)))?; + Ok((Arc::new(action), from, owner)) +} + +/// Snapshot an unpublished draft without inserting it into the library or committing it. +/// `from` is an original step index; disabled/dialog indices stay attached to that snapshot. +pub fn preview(s: &mut Session, draft: u64, from: usize) -> Result<(Arc, Owner)> { + super::not_playing(s, "actions.editor.preview")?; + let action = Arc::new(super::editor::preview_value(s, draft)?); + if from > action.steps.len() { + return Err(bad("actions.editor.preview", "preview step is outside the draft")); + } + let owner = suspend(s, Some((draft, action.clone())), None)?.ok_or_else(|| bad("actions.editor.preview", "preview ownership is unavailable"))?; + Ok((action, owner)) +} + +impl Owner { + pub fn is_preview(&self) -> bool { + self.preview.is_some() + } + + pub fn draft_id(&self) -> Option { + self.preview.as_ref().map(|(id, _)| *id) + } + + /// Check before executing a confirmed command, not only before its later continuation. + pub fn validate(&self, s: &Session) -> Result<()> { + if s.actions.held_playback != Some(self.serial) || s.actions.take != self.take || s.actions.recording_active() { + return Err(bad("actions.play", "this playback no longer owns its capture")); + } + let destination_exists = match &self.destination { + Some(Destination::Classic(action)) => classic_index(s, action).is_some(), + Some(Destination::Draft(id)) => s.actions.editor.as_ref().is_some_and(|draft| draft.id == *id), + None => true, + }; + if !destination_exists { + return Err(bad("actions.play", "the recording destination has changed or no longer exists")); + } + if let Some((id, snapshot)) = &self.preview + && !super::editor::preview_matches(s, *id, snapshot) + { + return Err(bad("actions.editor.preview", "the preview draft has changed")); + } + Ok(()) + } + + /// Release exactly once at completion, cancel or failure. Preview never records a self-call. + /// Normal document work remains; only capture ownership and its cursor are restored. + pub fn finish(self, s: &mut Session) { + if s.actions.held_playback != Some(self.serial) { + return; + } + s.actions.held_playback = None; + if s.actions.take != self.take || s.actions.recording_active() { + return; + } + match self.destination { + Some(Destination::Classic(action)) => { + // A reordered identical owner is legitimate; a reused name/index is not. + if let Some(index) = classic_index(s, &action) { + s.actions.recording = Some((s.journal.len(), index)); + } + } + Some(Destination::Draft(id)) => { + s.actions.restore_capture(Some(Recording::Draft { from: s.journal.len(), owner: id, take: self.take })); + } + None => {} + } + if s.actions.recording_active() + && let Some((action, from)) = self.call + { + super::journal_call(s, &action, from); + } + } +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index cdd4af99055..c7a6bccfa86 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -1209,6 +1209,8 @@ impl Session { } crate::param_check::check(spec, ¶ms)?; crate::filters::validate_params(id, ¶ms)?; + // A paused action owns its capture before preparatory document effects too. + crate::actions_cmds::playback::admit(self, id)?; // A floating selection drops before any other command (Undo puts it back instead). if let Some(v) = crate::float_cmds::before_command(self, id)? { return Ok(Started::Done(v)); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index d7916fe5486..42e01d6f38e 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -95,6 +95,7 @@ impl Session { /// document, a hidden target. The run params (the Channels panel's target filled in) when it /// may run. pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result { + crate::actions_cmds::playback::admit(self, id)?; let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone())); if let Some(why) = color_channels::refusal(self, id, &run_params) { return Err(EngineError::Disabled(id.into(), why)); diff --git a/crates/engine/tests/it/actions_owned_preview.rs b/crates/engine/tests/it/actions_owned_preview.rs new file mode 100644 index 00000000000..79f5915756b --- /dev/null +++ b/crates/engine/tests/it/actions_owned_preview.rs @@ -0,0 +1,127 @@ +//! Direct Session consumers must respect the same shell-owned preview lease as the UI. +use photocraft_engine::{ + Session, + actions_cmds::{Action, playback}, + jobs::Started, +}; +use serde_json::{Value, json}; + +fn session() -> Session { + let mut session = Session::new(); + session.execute("file.new", json!({"width":40,"height":30,"background":"#804020"})).unwrap(); + session +} + +fn draft(session: &mut Session) -> u64 { + let id = session.execute("actions.editor.open", json!({"name":"Unpublished"})).unwrap()["draft"].as_u64().unwrap(); + session.execute("actions.editor.step", json!({"draft":id,"op":"insert","step":0,"id":"layer.renameLayer","params":{"name":"Preview"}})).unwrap(); + session.execute("actions.editor.update", json!({"draft":id,"description":"Retain me","icon":"FutureIcon"})).unwrap(); + id +} + +fn get(session: &mut Session, id: u64) -> Value { + session.execute("actions.editor.get", json!({"draft":id})).unwrap() +} + +#[test] +fn direct_commands_cannot_invalidate_preview_and_finish_restores_only_capture() { + let mut session = session(); + let id = draft(&mut session); + session.execute("actions.editor.record", json!({"draft":id})).unwrap(); + session.execute("select.all", json!({})).unwrap(); + let visible = get(&mut session, id)["action"].clone(); + let take = session.actions.take; + let (snapshot, owner) = playback::preview(&mut session, id, 0).unwrap(); + assert_eq!(serde_json::to_value(snapshot.as_ref()).unwrap(), visible); + assert_eq!(snapshot.description, "Retain me"); + assert_eq!(snapshot.icon, "FutureIcon"); + let before = session.actions.clone(); + for (command, params) in [ + ("actions.editor.save", json!({"draft":id})), + ("actions.editor.discard", json!({"draft":id})), + ("actions.editor.update", json!({"draft":id,"name":"Other"})), + ("actions.record", json!({"name":"Intruder"})), + ] { + assert!(session.execute(command, params).is_err()); + assert_eq!(session.actions, before); + } + owner.validate(&session).unwrap(); + session.execute("layer.renameLayer", json!({"name":"Document work"})).unwrap(); + owner.finish(&mut session); + assert_eq!(session.actions.take, take); + assert_eq!(get(&mut session, id)["action"], visible); + assert_eq!(get(&mut session, id)["recording"], true); + assert!(session.actions.list.is_empty()); + assert!(session.actions.held_playback.is_none()); + assert_eq!(session.active().unwrap().doc.layer(session.active().unwrap().active_layer.unwrap()).unwrap().name, "Document work"); +} + +#[test] +fn stale_take_never_restores_over_a_later_classic_recording() { + let mut session = session(); + let id = draft(&mut session); + session.execute("actions.editor.record", json!({"draft":id})).unwrap(); + let (_, owner) = playback::preview(&mut session, id, 0).unwrap(); + session.actions.take += 1; + session.actions.list.push(Action::new("Later", Vec::new())); + let later = Some((session.journal.len(), 0)); + session.actions.recording = later; + assert!(owner.validate(&session).is_err()); + owner.finish(&mut session); + assert_eq!(session.actions.recording, later); + assert!(session.actions.held_playback.is_none()); + assert_eq!(get(&mut session, id)["recording"], false); +} + +#[test] +fn a_refused_definition_edit_cannot_drop_the_floating_selection_first() { + let mut session = session(); + let id = draft(&mut session); + let (_, owner) = playback::preview(&mut session, id, 0).unwrap(); + session.execute("select.rect", json!({"x":1,"y":1,"width":5,"height":4})).unwrap(); + session.execute("select.float", json!({"dx":3,"dy":0,"copy":true})).unwrap(); + let document = session.active().unwrap().doc.clone(); + let history = session.active().unwrap().history.entries(); + let journal = session.journal.clone(); + assert!(photocraft_engine::commands::float_cmds::floating(session.active().unwrap()).is_some()); + assert!(session.execute("actions.editor.update", json!({"draft":id,"name":"Rejected"})).is_err()); + assert!(std::sync::Arc::ptr_eq(&document, &session.active().unwrap().doc)); + assert_eq!(session.active().unwrap().history.entries(), history); + assert_eq!(session.journal, journal); + assert!(photocraft_engine::commands::float_cmds::floating(session.active().unwrap()).is_some()); + owner.finish(&mut session); + assert!(photocraft_engine::commands::float_cmds::floating(session.active().unwrap()).is_some()); +} + +#[test] +fn preview_waits_for_prior_jobs_and_later_unowned_jobs_stay_out_of_capture() { + let mut session = session(); + session + .edit("pattern", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let mut pixels = Vec::new(); + for y in 0..30 { + for x in 0..40 { + let v = ((x * 7 + y * 13) % 31) as f32 / 30.0; + pixels.extend_from_slice(&[v, 1.0 - v, 0.5, 1.0]); + } + } + surface.write_region(photocraft_geom::Rect::new(0, 0, 40, 30), &pixels); + Ok(()) + }) + .unwrap(); + let id = draft(&mut session); + session.execute("actions.editor.record", json!({"draft":id})).unwrap(); + let Started::Job(first) = session.start("filter.blur.gaussianBlur", json!({"radius":1})).unwrap() else { panic!("real job required") }; + let before = session.actions.clone(); + assert!(playback::preview(&mut session, id, 0).is_err()); + assert_eq!(session.actions, before); + session.wait_job(first).unwrap(); + let visible = get(&mut session, id)["action"].clone(); + let (_, owner) = playback::preview(&mut session, id, 0).unwrap(); + let Started::Job(later) = session.start("filter.blur.gaussianBlur", json!({"radius":2})).unwrap() else { panic!("real job required") }; + owner.finish(&mut session); + session.wait_job(later).unwrap(); + assert_eq!(get(&mut session, id)["action"], visible, "the unowned late job does not enter the restored capture"); + assert_eq!(get(&mut session, id)["recording"], true); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 09ebe260fd4..a5428b22eeb 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod action_metadata; mod actions_editor_draft; +mod actions_owned_preview; mod affinity; mod artboard_export_names; mod atn_action_colors; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0e527bf908f..fdbb4cae3d7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1038,6 +1038,11 @@ fn confirm_command_more(app: &mut PhotocraftApp, d: Dialog) -> Result Result { confirm_preflight(app, id)?; let d = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; + let fields = d.fields.clone(); + crate::actions::with_step_gate(app, &fields, Some(id), |app| confirm_closed(app, d)) +} + +fn confirm_closed(app: &mut PhotocraftApp, d: Dialog) -> Result { match d.kind { DialogKind::NewDocument => { let r = app.run("file.new", crate::new_doc_ui::command_params(&d.fields)); diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 0f87c26faff..3d87a0ea72d 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -1110,14 +1110,16 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: }; let mut fields = fields; fields.insert("mapPath".into(), json!(path)); - app.refocus(doc)?; - let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?; - let result = app.run(&cmd, params); - if result.is_ok() { - remember(app, &cmd, &fields); - let _ = crate::actions::resume(app, &fields); - } - result + crate::actions::with_step_gate(app, &fields, None, |app| { + app.refocus(doc)?; + let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?; + let result = app.run(&cmd, params); + if result.is_ok() { + remember(app, &cmd, &fields); + crate::actions::resume(app, &fields)?; + } + result + }) }) } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 2642773f205..58a2a77eab5 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -122,7 +122,7 @@ fn play_targets(app: &mut PhotocraftApp, targets: &[usize], before: u64) -> Resu let rest: Vec = targets.get(n + 1..).unwrap_or_default().iter().filter_map(|i| list.get(*i).cloned()).collect(); let token = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64); if let Some(entry) = token.and_then(|t| app.paused_playbacks.paused.get_mut(&t)) - && name.as_ref() == Some(&entry.action) + && name.as_ref() == Some(&entry.frame.action.name) { entry.then = rest; entry.ran = ran; @@ -171,39 +171,148 @@ pub(crate) fn assign_shortcut(app: &mut PhotocraftApp, name: &str, shortcut: &st app.run("edit.keyboardShortcuts", json!({"set": set, "allowUnknown": true, "removeConflicts": false})) } -/// Replay edits synchronously, with view steps handled by the shell in the same order. -/// Engine steps keep the headless action semantics (no dialogs or background jobs). -pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { - play_owned(app, params, None) +/// An immutable source value; library continuation never resolves merely by a reused name. +#[derive(Clone, Copy, Debug)] +enum Source { + Library, + Draft(u64), } -/// `suspended` is the recording a paused part of the same call took ([`PausedPlayback::record`]); it comes -/// back, with that named call journaled, only when the whole call ends. -fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) -> Result { - let (action, from) = match actions_cmds::playback_plan(&app.session, params) { - Ok(plan) => plan, - Err(e) => { - restore_recording(app, suspended.as_ref()); - return Err(e.to_string()); +#[derive(Clone, Debug)] +struct Frame { + action: std::sync::Arc, + source: Source, + from: usize, + take: u64, +} + +impl Frame { + fn library(session: &photocraft_engine::Session, params: &Value) -> Result { + let (action, from) = actions_cmds::playback_plan(session, params).map_err(|e| e.to_string())?; + Ok(Self { action: std::sync::Arc::new(action), source: Source::Library, from, take: session.actions.take }) + } + + fn after(&self, from: usize) -> Self { + Self { from, ..self.clone() } + } + + fn validate(&self, session: &photocraft_engine::Session) -> Result<(), String> { + if self.take != session.actions.take { + return Err("this playback belongs to an earlier recording take".into()); } - }; - let recording = match actions_cmds::begin_playback(&mut app.session, &action) { - Ok(r) => r, - Err(e) => { - restore_recording(app, suspended.as_ref()); - return Err(e.to_string()); + if matches!(self.source, Source::Library) && !session.actions.list.iter().any(|a| a == self.action.as_ref()) { + return Err("the paused library action has changed or no longer exists".into()); } + Ok(()) + } +} + +/// Replay a deliberate named library call, capturing one call in either recorder. +pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result { + let (action, from, record) = actions_cmds::playback::library(&mut app.session, params).map_err(|e| e.to_string())?; + let frame = Frame { action, from, source: Source::Library, take: app.session.actions.take }; + let used = if app.session.actions.playing > 0 { app.session.actions.playback_steps } else { 0 }; + run_owned(app, frame, Vec::new(), record, 0, used, false) +} + +/// Preview a complete unpublished snapshot. The editor page can call this without Save or a +/// temporary library row; normal commands edit the real document and never record a self-call. +pub fn preview_draft(app: &mut PhotocraftApp, draft: u64, from: usize) -> Result { + let (action, record) = actions_cmds::playback::preview(&mut app.session, draft, from).map_err(|e| e.to_string())?; + let frame = Frame { action, from, source: Source::Draft(draft), take: app.session.actions.take }; + run_owned(app, frame, Vec::new(), Some(record), 0, 0, true) +} + +/// Run a snapshot and any draft-preview parent tails; transfer its owner only at a real pause. +fn run_owned( + app: &mut PhotocraftApp, + mut frame: Frame, + mut parents: Vec, + mut record: Option, + mut ran: u64, + mut used: u64, + preserve_tail: bool, +) -> Result { + let result = (|| loop { + if let Some(owner) = &record { + owner.validate(&app.session).map_err(|e| e.to_string())?; + } + if let Source::Draft(id) = frame.source + && record.as_ref().and_then(|owner| owner.draft_id()) != Some(id) + { + return Err("the draft preview has lost its owner".into()); + } + let mut value = run_with_parents(app, frame.clone(), &parents, used, preserve_tail)?; + used = app.session.actions.playback_steps; + ran = ran.saturating_add(value.get("ran").and_then(Value::as_u64).unwrap_or(0)); + if let Some(dialog) = value.get("paused").and_then(|p| p.get("dialog")).and_then(Value::as_u64) { + let token = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64); + let entry = token.and_then(|token| app.paused_playbacks.paused.get_mut(&token)).ok_or("the action step dialog lost its continuation")?; + entry.parents.extend(parents); + entry.used = used; + entry.ran = ran; + entry.record = record.take(); + if let Some(object) = value.as_object_mut() { + object.insert("ran".into(), json!(ran)); + } + return Ok(value); + } + if value.get("failed").is_some() || parents.is_empty() { + if let Some(object) = value.as_object_mut() { + object.insert("ran".into(), json!(ran)); + } + return Ok(value); + } + frame = parents.remove(0); + })(); + if let Some(record) = record { + record.finish(&mut app.session); + } + result +} + +/// A resumed child still belongs to its preview ancestors for cycle/depth checks. The shell +/// unwinds the engine stack while a dialog is open, so install it only around this slice. +fn run_with_parents(app: &mut PhotocraftApp, frame: Frame, parents: &[Frame], used: u64, preserve_tail: bool) -> Result { + if parents.len() >= 16 { + return Err("action nesting exceeds 16 levels".into()); + } + let saved_depth = app.session.actions.playing; + let saved_stack = if saved_depth == 0 { + let stack = parents.iter().rev().map(|frame| frame.action.name.clone()).collect(); + let saved = std::mem::replace(&mut app.session.actions.playback_stack, stack); + app.session.actions.playing = u8::try_from(parents.len()).map_err(|_| "action nesting exceeds 16 levels")?; + Some(saved) + } else { + None }; - let owner = recording - .map(|(_, idx)| { - let into = app.session.actions.list.get(idx).map(|a| a.name.clone()); - json!({"index": idx, "into": into, "action": action.name, "from": from}) - }) - .or(suspended); + let result = run_frame(app, frame, Some(used), preserve_tail); + if let Some(stack) = saved_stack { + app.session.actions.playback_stack = stack; + app.session.actions.playing = saved_depth; + } + result +} + +fn pause_frame(app: &mut PhotocraftApp, frame: &Frame, from: usize, dialog: u64) -> Result { + if app.ui.dialogs.iter().all(|d| d.id != dialog) { + return Err("the action step dialog could not be opened".into()); + } + app.paused_playbacks.pause(frame.after(from), dialog, app.session.authorize, app.session.actions.playback_steps) +} + +/// Execute one immutable frame with normal shell view/save, authorization and job ordering. +fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_tail: bool) -> Result { + frame.validate(&app.session)?; + let action = frame.action.clone(); + let _ = actions_cmds::begin_playback(&mut app.session, &action).map_err(|e| e.to_string())?; + if let Some(used) = used { + app.session.actions.playback_steps = used; + } let mut ran = 0u64; let mut failed = None; let mut paused = None; - for (i, (id, p)) in action.enabled_steps().skip_while(|(i, _)| *i < from) { + for (i, (id, p)) in action.enabled_steps().skip_while(|(i, _)| *i < frame.from) { if !actions_cmds::consume_playback_step(&mut app.session) { failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", actions_cmds::MAX_PLAYBACK_STEPS)})); break; @@ -229,10 +338,16 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) { let label = label_of(id); let dialog = crate::dialogs::open_command_dialog(app, id, &label); - let token = app.paused_playbacks.pause(action.name.clone(), i + 1, app.session.authorize); + let token = match pause_frame(app, &frame, i + 1, dialog) { + Ok(token) => token, + Err(error) => { + app.ui.close_dialog(dialog); + failed = Some(json!({"step": i, "id": id, "error": error})); + break; + } + }; if let Some(d) = app.ui.dialog_mut(dialog) { if let Some(o) = p.as_object() { - // `__` keys are the dialog's own markers (`params_of` drops them), not step values. d.fields.extend(o.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone()))); } d.fields.insert(RESUME.into(), json!(token)); @@ -242,7 +357,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } let target = app.session.active().map(|d| d.doc.id); let result = if id == "actions.play" { - play(app, p) + Frame::library(&app.session, p).and_then(|child| run_frame(app, child, None, preserve_tail)) } else if actions_cmds::saves_smart_contents(&app.session, id) { app.session.execute(actions_cmds::SAVE_CONTENTS, json!({})).map_err(|e| e.to_string()) } else if actions_cmds::shell_save_command(id) { @@ -268,7 +383,18 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) break; } if let Some(inner) = value.get("paused").filter(|p| p.is_object()) { - // A called action paused for a dialog; it resumes alone when confirmed. + if preserve_tail { + let token = inner + .get("dialog") + .and_then(Value::as_u64) + .and_then(|dialog| app.ui.dialog_mut(dialog)) + .and_then(|d| d.fields.get(RESUME)) + .and_then(Value::as_u64); + if let Some(entry) = token.and_then(|token| app.paused_playbacks.paused.get_mut(&token)) { + entry.parents.push(frame.after(i + 1)); + ran = ran.saturating_add(value.get("ran").and_then(Value::as_u64).unwrap_or(0)); + } + } paused = Some(inner.clone()); break; } @@ -280,19 +406,7 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } } } - actions_cmds::finish_playback(&mut app.session, None, &action, from); - match paused.as_ref().and_then(|p| p.get("dialog")).and_then(Value::as_u64).and_then(|d| app.ui.dialog_mut(d)) { - // Paused for a dialog: the call goes on after it, so recording stays suspended. - Some(d) if failed.is_none() => { - let entry = d.fields.get(RESUME).and_then(Value::as_u64).and_then(|t| app.paused_playbacks.paused.get_mut(&t)); - match (owner, entry) { - (Some(owner), Some(p)) => p.record = Some(owner), - // No continuation to carry the recording: the call ends here. - (owner, _) => restore_recording(app, owner.as_ref()), - } - } - _ => restore_recording(app, owner.as_ref()), - } + actions_cmds::finish_playback(&mut app.session, None, &action, frame.from); Ok(match (failed, paused) { (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), @@ -723,78 +837,42 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let Some(owner) = owner else { return }; - let (Some(idx), Some(into), Some(name)) = ( - owner.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()), - owner.get("into").and_then(Value::as_str), - owner.get("action").and_then(Value::as_str), - ) else { - return; - }; - let from = owner.get("from").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0); - // `suspended_guard` refuses what would move the recorded action, but another command may - // still replace the list: the call goes to the action of that name, never to whatever now - // sits at the old index, and is dropped if that action is gone. - let list = &app.session.actions.list; - let target = if list.get(idx).is_some_and(|a| a.name == into) { Some(idx) } else { list.iter().position(|a| a.name == into) }; - if let Some(idx) = target - && app.session.actions.recording.is_none() - { - actions_cmds::restore_recording(&mut app.session, idx, name, from); - } -} - -/// Whether a paused playback holds a suspended recording. +/// Whether a paused playback holds an owned capture or unpublished preview. fn suspended(app: &PhotocraftApp) -> bool { app.paused_playbacks.paused.values().any(|p| p.record.is_some()) } -/// While a step dialog holds a suspended recording, refuse what would lose its call: a new -/// recording (the restore would find recording taken) or deleting/moving actions (the recorded -/// action could go away or change place). The engine checks these against `recording`, which -/// is `None` then. +/// Editor and Classic ownership-changing requests wait for the owning step dialog's cleanup. pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> { - if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) { + if suspended(app) && id.starts_with("actions.") && !matches!(id, "actions.list" | "actions.get" | "actions.editor.get" | "actions.save") { return Err(tl!("Confirm or cancel the open action step dialog first").into()); } Ok(()) } -/// A step dialog's command failed: the action ends there. +/// A failed step ends its owner once, without rolling back document work. pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map) { - let record = fields.get(RESUME).and_then(|t| app.paused_playbacks.take(t)).and_then(|p| p.record); - restore_recording(app, record.as_ref()); + if let Some(paused) = fields.get(RESUME).and_then(|token| app.paused_playbacks.take(token)) + && let Some(record) = paused.record + { + record.finish(&mut app.session); + } } -/// The automation gate a session installs ([`photocraft_engine::Session::authorize`]). type Gate = fn(&str, &Value) -> photocraft_engine::Result<()>; -/// A playback paused at a step dialog: the action and step it continues from, and the gate it -/// was playing under (`None` for one a person started). -#[derive(Clone, Debug)] +#[derive(Debug)] pub(crate) struct PausedPlayback { - action: String, - from: usize, + frame: Frame, + dialog: u64, gate: Option, - /// The recording the paused call suspended: `{index, into, action, from}` (`into` is the - /// name of the action being recorded). A step dialog only closes through - /// `dialogs::confirm` (OK, `↩`, automation) or `dialogs::cancel` (Cancel, Esc, the close - /// box, automation), which end the call through `resume`, `abandoned` or `stopped`, so the - /// recording comes back exactly once. - record: Option, - /// The actions still to play after this one, as they were when Play started a whole set. - /// Actions have no id, so the whole action stands in for one: an action deleted and made again - /// under the same name, or edited, while the dialog is open no longer matches. + record: Option, + parents: Vec, then: Vec, - /// The steps that Play ran before this pause (earlier actions of the set, and this one's - /// steps before the dialog), so the final count covers the whole Play. ran: u64, + used: u64, } -/// The paused playbacks, by the token their dialog carries. The dialog's fields hold only the -/// token; what plays next, and under which gate, is kept here. #[derive(Debug, Default)] pub(crate) struct PausedPlaybacks { last: u64, @@ -802,10 +880,11 @@ pub(crate) struct PausedPlaybacks { } impl PausedPlaybacks { - fn pause(&mut self, action: String, from: usize, gate: Option) -> u64 { - self.last = self.last.wrapping_add(1); - self.paused.insert(self.last, PausedPlayback { action, from, gate, record: None, then: Vec::new(), ran: 0 }); - self.last + fn pause(&mut self, frame: Frame, dialog: u64, gate: Option, used: u64) -> Result { + let token = self.last.checked_add(1).ok_or("action step dialog identities exhausted")?; + self.last = token; + self.paused.insert(token, PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used }); + Ok(token) } fn take(&mut self, token: &Value) -> Option { @@ -813,76 +892,120 @@ impl PausedPlaybacks { } } -/// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this). -/// -/// The remaining steps run under the gate the playback paused with, however the dialog was -/// confirmed: a control request, synthetic Enter or OK, or a click. +/// Validate the actual pause before its command/refocus effect, and use its original gate. +/// `dialog` is supplied by immediate confirmation; deferred callbacks retain the protected token. +pub(crate) fn with_step_gate( + app: &mut PhotocraftApp, + fields: &serde_json::Map, + dialog: Option, + run: impl FnOnce(&mut PhotocraftApp) -> Result, +) -> Result { + let Some(token) = fields.get(RESUME) else { + if let Some(original) = dialog.and_then(|dialog| app.paused_playbacks.paused.iter().find_map(|(token, p)| (p.dialog == dialog).then_some(*token))) { + abandoned(app, &serde_json::Map::from_iter([(RESUME.to_string(), json!(original))])); + return Err("the action step dialog lost its continuation token".into()); + } + return run(app); + }; + let check = (|| { + let paused = token.as_u64().and_then(|token| app.paused_playbacks.paused.get(&token)).ok_or("this dialog doesn't belong to a paused action")?; + if dialog.is_some_and(|dialog| dialog != paused.dialog) { + return Err("this dialog doesn't belong to that paused action".to_string()); + } + paused.frame.validate(&app.session)?; + if let Some(owner) = &paused.record { + owner.validate(&app.session).map_err(|e| e.to_string())?; + } + Ok(paused.gate) + })(); + let gate = match check { + Ok(gate) => gate, + Err(error) => { + // A tampered/stale marker must clean up the real dialog owner, not another pause. + let original = dialog.and_then(|dialog| app.paused_playbacks.paused.iter().find_map(|(token, p)| (p.dialog == dialog).then_some(*token))); + if let Some(token) = original { + abandoned(app, &serde_json::Map::from_iter([(RESUME.to_string(), json!(token))])); + } else if dialog.is_none() { + abandoned(app, fields); + } + return Err(error); + } + }; + let saved = app.session.authorize; + app.session.authorize = gate.or(saved); + let result = run(app); + if result.is_err() { + abandoned(app, fields); + } + app.session.authorize = saved; + result +} + +/// Continue a one-use pause; a draft preview retains parent tails, gate and shared step budget. pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { let Some(token) = fields.get(RESUME) else { return Ok(()) }; let Some(paused) = app.paused_playbacks.take(token) else { return Err("this dialog doesn't belong to a paused action".into()); }; - let owner = paused.record.clone(); - // The dialog's command may run as a background job; the next step needs its result. - if let Some(doc) = app.session.active().map(|d| d.doc.id) - && let Some(job) = app.session.job_on(doc) - && let Err(e) = app.session.wait_job(job.id) - { - restore_recording(app, owner.as_ref()); - let template = "Action playback failed: {error}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]); + let valid = paused.frame.validate(&app.session).and_then(|()| match &paused.record { + Some(record) => record.validate(&app.session).map_err(|error| error.to_string()), + None => Ok(()), + }); + if let Err(error) = valid { + if let Some(record) = paused.record { + record.finish(&mut app.session); + } + app.ui.status = crate::i18n::fmt(tl!("Action playback failed: {error}"), &[("error", &error)]); app.ui.status_error = true; - return Err(e.to_string()); + return Err(error); + } + let wait = app.session.active().map(|d| d.doc.id).and_then(|doc| app.session.job_on(doc)); + if let Some(job) = wait + && let Err(error) = app.session.wait_job(job.id) + { + if let Some(record) = paused.record { + record.finish(&mut app.session); + } + app.ui.status = crate::i18n::fmt(tl!("Action playback failed: {error}"), &[("error", &error.to_string())]); + app.ui.status_error = true; + return Err(error.to_string()); } - let params = json!({"action": paused.action, "from": paused.from}); let saved = app.session.authorize; app.session.authorize = paused.gate.or(saved); - let mut played = play_owned(app, ¶ms, owner); - // Paused again at a later dialog of the same action (not of one it calls, which resumes - // alone): that dialog carries the rest of the set. - if let Ok(v) = &played - && let Some(dialog) = v.get("paused").and_then(|p| p.get("dialog")).and_then(Value::as_u64) + let preserve_tail = paused.record.as_ref().is_some_and(|owner| owner.is_preview()); + let source = paused.frame.action.clone(); + let mut played = run_owned(app, paused.frame, paused.parents, paused.record, paused.ran.saturating_add(1), paused.used, preserve_tail); + if let Ok(value) = &played + && let Some(dialog) = value.get("paused").and_then(|p| p.get("dialog")).and_then(Value::as_u64) && let Some(token) = app.ui.dialog_mut(dialog).and_then(|d| d.fields.get(RESUME)).and_then(Value::as_u64) && let Some(entry) = app.paused_playbacks.paused.get_mut(&token) - && entry.action == paused.action + && entry.frame.action == source { entry.then = paused.then.clone(); - entry.ran = paused.ran.saturating_add(1).saturating_add(v.get("ran").and_then(Value::as_u64).unwrap_or(0)); } - // The count covers the steps this Play ran before the pause, and the step the dialog ran. - let ran = paused.ran.saturating_add(1).saturating_add(played.as_ref().ok().and_then(|v| v.get("ran")).and_then(Value::as_u64).unwrap_or(0)); - if let Ok(Some(o)) = played.as_mut().map(Value::as_object_mut) - && o.contains_key("ran") - { - o.insert("ran".into(), json!(ran)); - } - // The rest of a set Play started: under the same gate, and only if this action finished. An - // action of it deleted, renamed, edited or replaced by a new one of the same name meanwhile - // ends the set there. - if let Ok(v) = &played + if let Ok(value) = &played && !paused.then.is_empty() - && v.get("failed").is_none() - && v.get("paused").is_none() + && value.get("failed").is_none() + && value.get("paused").is_none() { - let list = &app.session.actions.list; - let rest: Vec = paused.then.iter().map_while(|then| list.iter().position(|a| a == then)).collect(); + let rest: Vec = paused.then.iter().map_while(|then| app.session.actions.list.iter().position(|a| a == then)).collect(); + let ran = value.get("ran").and_then(Value::as_u64).unwrap_or(0); match play_targets(app, &rest, ran) { - Ok(Some(r)) => played = Ok(r), + Ok(Some(value)) => played = Ok(value), Ok(None) => {} - Err(e) => played = Err(e), + Err(error) => played = Err(error), } } app.session.authorize = saved; match played { - Ok(v) => { - report_play(app, &v); + Ok(value) => { + report_play(app, &value); Ok(()) } - Err(e) => { - let template = "Action playback failed: {error}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + Err(error) => { + app.ui.status = crate::i18n::fmt(tl!("Action playback failed: {error}"), &[("error", &error)]); app.ui.status_error = true; - Err(e) + Err(error) } } } @@ -1582,4 +1705,6 @@ fn draw_step_controls(ui: &mut egui::Ui, rect: Rect, step: usize, label: &str, c #[cfg(test)] mod metadata_color_tests; #[cfg(test)] +mod owned_preview_tests; +#[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs new file mode 100644 index 00000000000..673b75a0901 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs @@ -0,0 +1,280 @@ +//! Windowless consumers of the real preview, confirmation and deferred-picker routes. +use super::*; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap(); + app +} + +fn draft(app: &mut PhotocraftApp, steps: &[(&str, Value)]) -> u64 { + let id = app.run("actions.editor.open", json!({"name":"Unpublished"})).unwrap()["draft"].as_u64().unwrap(); + for (index, (command, params)) in steps.iter().enumerate() { + app.run("actions.editor.step", json!({"draft":id,"op":"insert","step":index,"id":command,"params":params})).unwrap(); + } + id +} + +fn get(app: &mut PhotocraftApp, id: u64) -> Value { + app.run("actions.editor.get", json!({"draft":id})).unwrap() +} + +fn has_layer(app: &PhotocraftApp, name: &str) -> bool { + app.session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == name) +} + +fn dialog(app: &PhotocraftApp) -> u64 { + app.ui.dialogs.last().unwrap().id +} + +fn blur_draft(app: &mut PhotocraftApp) -> u64 { + let id = draft(app, &[("filter.blur.gaussianBlur", json!({"radius":1})), ("layer.new.layer", json!({"name":"Suffix"}))]); + app.run("actions.editor.step", json!({"draft":id,"op":"dialog","step":0,"enabled":true})).unwrap(); + id +} + +#[test] +fn unpublished_preview_preserves_snapshot_indices_flags_and_metadata() { + let mut app = app(); + let id = draft(&mut app, &[("layer.new.layer", json!({"name":"Disabled"})), ("layer.new.layer", json!({"name":"Visible"}))]); + app.run("actions.editor.update", json!({"draft":id,"description":"Uncommitted description","icon":"UnknownGlyph"})).unwrap(); + app.run("actions.editor.step", json!({"draft":id,"op":"enabled","step":0,"enabled":false})).unwrap(); + let before = get(&mut app, id); + let (library, revision, prefs) = (app.session.actions.list.clone(), app.session.actions.rev, app.session.prefs().clone()); + let played = preview_draft(&mut app, id, 0).unwrap(); + assert_eq!(played["ran"], 1); + assert!(!has_layer(&app, "Disabled")); + assert!(has_layer(&app, "Visible")); + assert_eq!(get(&mut app, id), before); + assert_eq!(app.session.actions.list, library); + assert_eq!(app.session.actions.rev, revision); + assert_eq!(app.session.prefs(), &prefs); + assert!(app.session.actions.held_playback.is_none()); +} + +#[test] +fn preview_resumes_its_capture_without_a_self_call_or_expanded_steps() { + let mut app = app(); + let id = draft(&mut app, &[("layer.renameLayer", json!({"name":"Preview name"}))]); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + app.run("layer.new.layer", json!({"name":"Captured before"})).unwrap(); + let before = get(&mut app, id)["action"].clone(); + let take = app.session.actions.take; + let journal = app.session.journal.len(); + preview_draft(&mut app, id, 0).unwrap(); + assert_eq!(get(&mut app, id)["action"], before); + assert_eq!(get(&mut app, id)["recording"], true); + assert_eq!(app.session.actions.take, take); + assert!(app.session.journal.len() > journal); + assert!(!app.session.journal[journal..].iter().any(|(id, _)| id == "actions.play")); + app.run("select.all", json!({})).unwrap(); + let stopped = app.run("actions.editor.stop", json!({"draft":id})).unwrap(); + assert_eq!(stopped["action"]["steps"].as_array().unwrap().len(), 3); + assert!(app.session.actions.list.is_empty()); +} + +#[test] +fn a_paused_named_library_call_captures_one_call_in_the_draft() { + let mut app = app(); + let mut child = Action::new("Child", vec![("filter.blur.gaussianBlur".into(), json!({"radius":1})), ("layer.new.layer".into(), json!({"name":"Suffix"}))]); + child.dialogs.insert(0); + app.session.actions.list.push(child); + let id = draft(&mut app, &[]); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + let played = play(&mut app, &json!({"action":"Child"})).unwrap(); + let dialog = played["paused"]["dialog"].as_u64().unwrap(); + for (command, params) in [ + ("actions.editor.save", json!({"draft":id})), + ("actions.editor.discard", json!({"draft":id})), + ("actions.editor.record", json!({"draft":id})), + ("actions.record", json!({"name":"Intruder"})), + ] { + assert!(app.run(command, params.clone()).is_err()); + assert!(app.session.execute(command, params).is_err(), "direct Session calls also respect the owner"); + } + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let stopped = app.run("actions.editor.stop", json!({"draft":id})).unwrap(); + assert_eq!(stopped["action"]["steps"], json!([["actions.play",{"action":"Child"}]])); + assert!(has_layer(&app, "Suffix")); + assert_eq!(app.session.actions.list.len(), 1); +} + +static PARENT_GATED: AtomicBool = AtomicBool::new(false); +fn original_gate(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "layer.new.layer" && params["name"] == "Parent tail" { + PARENT_GATED.store(true, Ordering::SeqCst); + } + Ok(()) +} + +#[test] +fn two_nested_pauses_keep_the_parent_tail_gate_and_one_use_tokens() { + PARENT_GATED.store(false, Ordering::SeqCst); + let mut app = app(); + let mut child = Action::new( + "Child", + vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius":1})), + ("filter.blur.gaussianBlur".into(), json!({"radius":2})), + ("layer.new.layer".into(), json!({"name":"Child tail"})), + ], + ); + child.dialogs.extend([0, 1]); + app.session.actions.list.push(child); + let id = draft( + &mut app, + &[ + ("layer.new.layer", json!({"name":"Parent prefix"})), + ("actions.play", json!({"action":"Child"})), + ("layer.new.layer", json!({"name":"Parent tail"})), + ], + ); + let before = get(&mut app, id); + app.session.authorize = Some(original_gate); + preview_draft(&mut app, id, 0).unwrap(); + let first = dialog(&app); + let fields = app.ui.dialog_mut(first).unwrap().fields.clone(); + app.session.authorize = None; + crate::dialogs::confirm(&mut app, first).unwrap(); + let second = dialog(&app); + assert_ne!(first, second); + assert_ne!(fields[RESUME], app.ui.dialog_mut(second).unwrap().fields[RESUME]); + let doc = app.session.active().unwrap().doc.clone(); + assert!(resume(&mut app, &fields).is_err()); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc)); + crate::dialogs::confirm(&mut app, second).unwrap(); + assert!(has_layer(&app, "Parent prefix")); + assert!(has_layer(&app, "Child tail")); + assert!(has_layer(&app, "Parent tail")); + assert!(PARENT_GATED.load(Ordering::SeqCst)); + assert_eq!(get(&mut app, id), before); + assert!(app.paused_playbacks.paused.is_empty()); + assert!(app.session.actions.held_playback.is_none()); +} + +#[test] +fn cancel_command_error_and_worker_error_restore_only_capture() { + for exit in ["cancel", "command error", "worker error"] { + let mut app = app(); + let id = draft( + &mut app, + &[ + ("layer.setProps", json!({"name":"Prefix","locks":{"pixels":exit == "worker error"}})), + ("filter.blur.gaussianBlur", json!({"radius":1})), + ("layer.new.layer", json!({"name":"Suffix"})), + ], + ); + app.run("actions.editor.step", json!({"draft":id,"op":"dialog","step":1,"enabled":true})).unwrap(); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + let before = get(&mut app, id)["action"].clone(); + app.background_jobs = exit == "worker error"; + preview_draft(&mut app, id, 0).unwrap(); + let dialog = dialog(&app); + if exit == "cancel" { + crate::dialogs::cancel(&mut app, dialog).unwrap(); + } else { + if exit == "command error" { + app.ui.dialog_mut(dialog).unwrap().fields.insert("radius".into(), json!("invalid")); + } + assert!(crate::dialogs::confirm(&mut app, dialog).is_err(), "{exit}"); + } + assert!(has_layer(&app, "Prefix"), "prefix document work remains: {exit}"); + assert!(!has_layer(&app, "Suffix")); + assert_eq!(get(&mut app, id)["action"], before); + assert_eq!(get(&mut app, id)["recording"], true); + assert!(app.session.actions.held_playback.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + } +} + +#[test] +fn stale_owner_or_missing_and_replayed_tokens_cannot_run_the_dialog_first() { + for fault in ["new take", "closed draft", "bad token", "missing token"] { + let mut app = app(); + let id = blur_draft(&mut app); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + preview_draft(&mut app, id, 0).unwrap(); + let dialog = dialog(&app); + let fields = app.ui.dialog_mut(dialog).unwrap().fields.clone(); + let doc = app.session.active().unwrap().doc.clone(); + match fault { + "new take" => { + app.session.actions.take += 1; + app.session.actions.list.push(Action::new("Later recorder", Vec::new())); + app.session.actions.recording = Some((app.session.journal.len(), 0)); + } + "closed draft" => app.session.actions.editor = None, + "bad token" => { + app.ui.dialog_mut(dialog).unwrap().fields.insert(RESUME.into(), json!({"forged":true})); + } + _ => { + app.ui.dialog_mut(dialog).unwrap().fields.remove(RESUME); + } + } + let recording = app.session.actions.recording; + assert!(crate::dialogs::confirm(&mut app, dialog).is_err(), "{fault}"); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "{fault}"); + assert_eq!(app.session.actions.recording, recording, "never overwrite a later take"); + assert!(app.session.actions.held_playback.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + assert!(resume(&mut app, &fields).is_err()); + } +} + +fn deny_map_path(id: &str, params: &Value) -> photocraft_engine::Result<()> { + if id == "filter.distort.displace" && params.get("mapPath").is_some() { + return Err(photocraft_engine::EngineError::Other("original gate refuses the map".into())); + } + Ok(()) +} + +#[test] +fn deferred_displace_checks_the_owner_and_gate_before_refocus_or_command() { + for stale in [false, true] { + let mut app = app(); + let id = draft(&mut app, &[("filter.distort.displace", json!({"horizontal":1,"vertical":1})), ("layer.new.layer", json!({"name":"Suffix"}))]); + app.run("actions.editor.step", json!({"draft":id,"op":"dialog","step":0,"enabled":true})).unwrap(); + let (show, _) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec!["not-read.png".into()]))]); + app.services.file_dialog = Some(show); + app.session.authorize = Some(deny_map_path); + preview_draft(&mut app, id, 0).unwrap(); + let dialog = dialog(&app); + app.session.authorize = None; + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.file_dialog_open()); + if stale { + app.session.execute("file.new", json!({"width":4,"height":4})).unwrap(); + app.session.actions.editor = None; + } + let target = app.session.active().unwrap().doc.clone(); + let active = app.session.active().unwrap().doc.id; + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.active().unwrap().doc.id, active, "stale callback must not refocus"); + assert!(std::sync::Arc::ptr_eq(&target, &app.session.active().unwrap().doc)); + assert!(app.ui.status_error); + if !stale { + assert!(app.ui.status.contains("original gate"), "{}", app.ui.status); + } + assert!(app.session.actions.held_playback.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + } +} + +#[test] +fn a_resumed_preview_keeps_its_tree_step_budget() { + let mut app = app(); + let id = blur_draft(&mut app); + app.run("actions.editor.step", json!({"draft":id,"op":"insert","step":2,"id":"layer.new.layer","params":{"name":"Overflow"}})).unwrap(); + preview_draft(&mut app, id, 0).unwrap(); + let dialog = dialog(&app); + let token = app.ui.dialog_mut(dialog).unwrap().fields[RESUME].as_u64().unwrap(); + app.paused_playbacks.paused.get_mut(&token).unwrap().used = actions_cmds::MAX_PLAYBACK_STEPS - 1; + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(has_layer(&app, "Suffix")); + assert!(!has_layer(&app, "Overflow")); + assert!(app.ui.status_error); + assert_eq!(app.session.actions.playback_steps, actions_cmds::MAX_PLAYBACK_STEPS); + assert!(app.session.actions.held_playback.is_none()); +} diff --git a/docs/atn.md b/docs/atn.md index 8ffb9f9854f..906576d0d0e 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -65,18 +65,28 @@ The backend editor commands keep one complete action draft outside the live libr restore an old library snapshot, erase the journal or undo document edits made while recording. Old editor identities cannot mutate a reopened editor. -Descriptions and icons currently persist in the native Actions library. ATN sidecar +Descriptions and icons temporarily persist through the existing native JSON Actions +library compatibility path, with defaults for older files. The required #11066 library +migration makes ATN with the measured sidecar the sole writable persisted library. +Legacy JSON is then a one-time migration input with a preserved backup, without +continued dual writing or new JSON-only metadata handling. +Actions belong to that library, not to `.pcraft` documents. ATN sidecar writing remains unimplemented, so ATN export refuses actions with a nonempty description or icon even when `skipUnsupported` is set; that option only permits omitting unsupported steps. Classic actions without this metadata retain their existing export behavior. -The separate `.pcraft` document attachment requirement remains unresolved. This is the backend increment for #10946, not the completed editor page. The UI's -Save/Discard/Cancel confirmation policy and its suspended-recording adapter remain -separate work. In particular, the legacy shell playback continuation carries a -library index, so direct shell playback refuses an active draft recording until -the editor UI owns that continuation. Headless `Session::execute(actions.play)` -already suspends/restores either recorder and captures one named call. No #10766 +Save/Discard/Cancel confirmation policy and page integration remain separate work. +The shell's owned preview adapter retains the complete unpublished snapshot, original +step indices and flags, authorization gate and step budget across dialogs. It resumes +the parent tail after a nested preview call pauses. Preview executes normal document +commands, with no temporary library row, definition Save, self-call or document rollback. +New definition edits wait for the owning step dialog to finish or cancel. A preview +waits for existing jobs before taking capture; jobs started by its step dialogs retain +the normal completion/failure and foreign-take handling. The raw legacy `begin_playback` +entry still refuses an active draft recorder; the owned adapter suspends it explicitly. +Headless `Session::execute(actions.play)` and deliberate shell library calls capture +one named call in either recorder. No #10766 or #10965 implementation is implicitly imported by this backend. ## Supported events (144) From 32f1af86c80f55e525b4491396b5e491b7d9186d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 14:42:54 -0800 Subject: [PATCH 080/384] Checkpoint Actions editor candidate for current-main integration --- ATTRIBUTION.md | 1 + assets/icons/action-editor/README.md | 20 + assets/icons/action-editor/adjust.svg | 4 + assets/icons/action-editor/black-white.svg | 4 + assets/icons/action-editor/blur.svg | 4 + assets/icons/action-editor/brush.svg | 4 + assets/icons/action-editor/burn.svg | 4 + assets/icons/action-editor/clarity.svg | 4 + assets/icons/action-editor/click.svg | 4 + assets/icons/action-editor/crop.svg | 4 + assets/icons/action-editor/dodge.svg | 4 + assets/icons/action-editor/download.svg | 4 + assets/icons/action-editor/erase.svg | 4 + assets/icons/action-editor/exposure.svg | 4 + assets/icons/action-editor/generate.svg | 4 + .../action-editor/generative-effects.svg | 4 + assets/icons/action-editor/grid.svg | 4 + assets/icons/action-editor/harmonize.svg | 4 + assets/icons/action-editor/hue.svg | 4 + assets/icons/action-editor/layer-effects.svg | 4 + assets/icons/action-editor/layers.svg | 4 + assets/icons/action-editor/lut.svg | 4 + assets/icons/action-editor/merge.svg | 4 + assets/icons/action-editor/object-select.svg | 4 + .../icons/action-editor/remove-background.svg | 4 + assets/icons/action-editor/sharpen.svg | 4 + assets/icons/action-editor/smart-remove.svg | 4 + assets/icons/action-editor/styles.svg | 4 + assets/icons/action-editor/subject.svg | 4 + assets/icons/action-editor/text-color.svg | 4 + assets/icons/action-editor/text.svg | 4 + assets/icons/action-editor/wand.svg | 4 + crates/engine/src/cmd/actions_cmds.rs | 80 +- crates/engine/src/cmd/actions_cmds/editor.rs | 14 + .../engine/src/cmd/actions_cmds/playback.rs | 26 +- crates/engine/src/cmd/actions_edit_cmds.rs | 136 ++- crates/engine/src/cmd/atn_cmds.rs | 21 +- crates/engine/src/cmd/atn_cmds/convert.rs | 163 +++- crates/engine/src/cmd/atn_cmds/files.rs | 167 +++- crates/engine/src/cmd/atn_cmds/sidecar.rs | 727 +++++++++++++++ .../engine/src/cmd/atn_cmds/sidecar_tests.rs | 612 ++++++++++++ crates/engine/src/jobs.rs | 5 + crates/engine/src/prefs.rs | 3 + crates/engine/src/shell_commands.rs | 2 + crates/engine/tests/it/action_metadata.rs | 44 +- crates/engine/tests/it/actions_duplicate.rs | 125 +++ .../engine/tests/it/actions_group_delete.rs | 133 +++ crates/engine/tests/it/main.rs | 2 + crates/psd/src/atn.rs | 85 +- crates/psd/src/atn/sets.rs | 105 +++ crates/psd/tests/atn_sidecar.rs | 233 +++++ crates/ui-egui/build.rs | 13 +- crates/ui-egui/src/app.rs | 7 + crates/ui-egui/src/app/commands.rs | 9 + crates/ui-egui/src/app/frame.rs | 3 + crates/ui-egui/src/control/inspect.rs | 1 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dialogs/discard_ui.rs | 5 +- crates/ui-egui/src/i18n/ar.tsv | 106 +++ crates/ui-egui/src/i18n/cs.tsv | 104 +++ crates/ui-egui/src/i18n/da.tsv | 104 +++ crates/ui-egui/src/i18n/de.tsv | 106 +++ crates/ui-egui/src/i18n/el.tsv | 104 +++ crates/ui-egui/src/i18n/es.tsv | 104 +++ crates/ui-egui/src/i18n/fi.tsv | 104 +++ crates/ui-egui/src/i18n/fr.tsv | 104 +++ crates/ui-egui/src/i18n/hu.tsv | 104 +++ crates/ui-egui/src/i18n/id.tsv | 106 +++ crates/ui-egui/src/i18n/it.tsv | 104 +++ crates/ui-egui/src/i18n/ja.tsv | 106 +++ crates/ui-egui/src/i18n/ko.tsv | 106 +++ crates/ui-egui/src/i18n/nb.tsv | 104 +++ crates/ui-egui/src/i18n/nl.tsv | 104 +++ crates/ui-egui/src/i18n/pl.tsv | 106 +++ crates/ui-egui/src/i18n/pt-br.tsv | 104 +++ crates/ui-egui/src/i18n/ru.tsv | 104 +++ crates/ui-egui/src/i18n/sr.tsv | 104 +++ crates/ui-egui/src/i18n/sv.tsv | 104 +++ crates/ui-egui/src/i18n/tr.tsv | 104 +++ crates/ui-egui/src/i18n/uk.tsv | 106 +++ crates/ui-egui/src/i18n/vi.tsv | 106 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 106 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 106 +++ crates/ui-egui/src/icon_data.rs | 30 + crates/ui-egui/src/icons.rs | 4 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus/enable_rules.rs | 8 +- crates/ui-egui/src/menus/state.rs | 6 + crates/ui-egui/src/panels/actions.rs | 119 ++- crates/ui-egui/src/panels/actions/editor.rs | 832 +++++++++++++++++ .../src/panels/actions/editor_glyph_tests.rs | 132 +++ .../src/panels/actions/editor_glyphs.rs | 46 + .../src/panels/actions/editor_picker.rs | 158 ++++ .../src/panels/actions/editor_picker_tests.rs | 192 ++++ .../src/panels/actions/editor_tests.rs | 333 +++++++ crates/ui-egui/src/panels/actions/modern.rs | 873 ++++++++++++++++++ .../src/panels/actions/new_layout_tests.rs | 326 +++++++ crates/ui-egui/src/panels/actions/tests.rs | 2 + crates/ui-egui/src/panels/panel_docking.rs | 16 + crates/ui-egui/src/prefs/prefs_ui/startup.rs | 1 + crates/ui-egui/src/state.rs | 23 + crates/ui-egui/src/theme.rs | 18 + 102 files changed, 8472 insertions(+), 135 deletions(-) create mode 100644 assets/icons/action-editor/README.md create mode 100644 assets/icons/action-editor/adjust.svg create mode 100644 assets/icons/action-editor/black-white.svg create mode 100644 assets/icons/action-editor/blur.svg create mode 100644 assets/icons/action-editor/brush.svg create mode 100644 assets/icons/action-editor/burn.svg create mode 100644 assets/icons/action-editor/clarity.svg create mode 100644 assets/icons/action-editor/click.svg create mode 100644 assets/icons/action-editor/crop.svg create mode 100644 assets/icons/action-editor/dodge.svg create mode 100644 assets/icons/action-editor/download.svg create mode 100644 assets/icons/action-editor/erase.svg create mode 100644 assets/icons/action-editor/exposure.svg create mode 100644 assets/icons/action-editor/generate.svg create mode 100644 assets/icons/action-editor/generative-effects.svg create mode 100644 assets/icons/action-editor/grid.svg create mode 100644 assets/icons/action-editor/harmonize.svg create mode 100644 assets/icons/action-editor/hue.svg create mode 100644 assets/icons/action-editor/layer-effects.svg create mode 100644 assets/icons/action-editor/layers.svg create mode 100644 assets/icons/action-editor/lut.svg create mode 100644 assets/icons/action-editor/merge.svg create mode 100644 assets/icons/action-editor/object-select.svg create mode 100644 assets/icons/action-editor/remove-background.svg create mode 100644 assets/icons/action-editor/sharpen.svg create mode 100644 assets/icons/action-editor/smart-remove.svg create mode 100644 assets/icons/action-editor/styles.svg create mode 100644 assets/icons/action-editor/subject.svg create mode 100644 assets/icons/action-editor/text-color.svg create mode 100644 assets/icons/action-editor/text.svg create mode 100644 assets/icons/action-editor/wand.svg create mode 100644 crates/engine/src/cmd/atn_cmds/sidecar.rs create mode 100644 crates/engine/src/cmd/atn_cmds/sidecar_tests.rs create mode 100644 crates/engine/tests/it/actions_duplicate.rs create mode 100644 crates/engine/tests/it/actions_group_delete.rs create mode 100644 crates/psd/src/atn/sets.rs create mode 100644 crates/psd/tests/atn_sidecar.rs create mode 100644 crates/ui-egui/src/panels/actions/editor.rs create mode 100644 crates/ui-egui/src/panels/actions/editor_glyph_tests.rs create mode 100644 crates/ui-egui/src/panels/actions/editor_glyphs.rs create mode 100644 crates/ui-egui/src/panels/actions/editor_picker.rs create mode 100644 crates/ui-egui/src/panels/actions/editor_picker_tests.rs create mode 100644 crates/ui-egui/src/panels/actions/editor_tests.rs create mode 100644 crates/ui-egui/src/panels/actions/modern.rs create mode 100644 crates/ui-egui/src/panels/actions/new_layout_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 700bccb19fb..82ea7e9c3e0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -36,6 +36,7 @@ next to them. | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/action-editor/*.svg` (30 files) | Actions editor glyphs on the 24 px grid | PhotoCraft contributors | Original geometry; identifier names from issue #10946 only | MIT OR Apache-2.0 | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/action-editor/README.md b/assets/icons/action-editor/README.md new file mode 100644 index 00000000000..5e77b86582a --- /dev/null +++ b/assets/icons/action-editor/README.md @@ -0,0 +1,20 @@ +# Actions editor glyphs + +Thirty original SVGs for the identifier grid measured in issue #10946. Geometry was +drawn for PhotoCraft from simple paths, circles and rectangles. Only the identifier +names and picker order come from the reference; no reference artwork was copied. +These assets are MIT OR Apache-2.0, as recorded in `ATTRIBUTION.md`. + +Each source uses a 24 px viewBox, currentColor and the existing 2 px round stroke +convention. The existing SvgIconSet pipeline recolours and overrides stroke width +at display time. No new renderer or dependencies are needed. + +The mapping is in `crates/ui-egui/src/panels/actions/editor_glyphs.rs`. It is not +registered in production yet. Add its consumer and the thirty names to the existing +embedded icon table together. Preserve each raw metadata identifier on save; +unknown identifiers display the existing neutral `square` icon, whose Lucide and +Feather attribution remains in the parent attribution table. + +Source tests prepare coverage of order, exact lookup, fallback and SvgIconSet's +byte transformation. Actual picker rendering, small-size legibility, light/dark +contrast, selection/focus and DPI qualification remain integration obligations. diff --git a/assets/icons/action-editor/adjust.svg b/assets/icons/action-editor/adjust.svg new file mode 100644 index 00000000000..1570454d556 --- /dev/null +++ b/assets/icons/action-editor/adjust.svg @@ -0,0 +1,4 @@ + + Adjustments + + diff --git a/assets/icons/action-editor/black-white.svg b/assets/icons/action-editor/black-white.svg new file mode 100644 index 00000000000..0348d1a5ea5 --- /dev/null +++ b/assets/icons/action-editor/black-white.svg @@ -0,0 +1,4 @@ + + Black and white + + diff --git a/assets/icons/action-editor/blur.svg b/assets/icons/action-editor/blur.svg new file mode 100644 index 00000000000..0f0b64d78db --- /dev/null +++ b/assets/icons/action-editor/blur.svg @@ -0,0 +1,4 @@ + + Blur + + diff --git a/assets/icons/action-editor/brush.svg b/assets/icons/action-editor/brush.svg new file mode 100644 index 00000000000..717ce4ad59c --- /dev/null +++ b/assets/icons/action-editor/brush.svg @@ -0,0 +1,4 @@ + + Brush + + diff --git a/assets/icons/action-editor/burn.svg b/assets/icons/action-editor/burn.svg new file mode 100644 index 00000000000..2b2484228b2 --- /dev/null +++ b/assets/icons/action-editor/burn.svg @@ -0,0 +1,4 @@ + + Burn + + diff --git a/assets/icons/action-editor/clarity.svg b/assets/icons/action-editor/clarity.svg new file mode 100644 index 00000000000..3efbd65573b --- /dev/null +++ b/assets/icons/action-editor/clarity.svg @@ -0,0 +1,4 @@ + + Clarity + + diff --git a/assets/icons/action-editor/click.svg b/assets/icons/action-editor/click.svg new file mode 100644 index 00000000000..d8692610a38 --- /dev/null +++ b/assets/icons/action-editor/click.svg @@ -0,0 +1,4 @@ + + Click action + + diff --git a/assets/icons/action-editor/crop.svg b/assets/icons/action-editor/crop.svg new file mode 100644 index 00000000000..8c3badb49fc --- /dev/null +++ b/assets/icons/action-editor/crop.svg @@ -0,0 +1,4 @@ + + Crop + + diff --git a/assets/icons/action-editor/dodge.svg b/assets/icons/action-editor/dodge.svg new file mode 100644 index 00000000000..7ab552f1572 --- /dev/null +++ b/assets/icons/action-editor/dodge.svg @@ -0,0 +1,4 @@ + + Dodge + + diff --git a/assets/icons/action-editor/download.svg b/assets/icons/action-editor/download.svg new file mode 100644 index 00000000000..4935d7b3a8f --- /dev/null +++ b/assets/icons/action-editor/download.svg @@ -0,0 +1,4 @@ + + Download + + diff --git a/assets/icons/action-editor/erase.svg b/assets/icons/action-editor/erase.svg new file mode 100644 index 00000000000..bf088f2d0eb --- /dev/null +++ b/assets/icons/action-editor/erase.svg @@ -0,0 +1,4 @@ + + Erase + + diff --git a/assets/icons/action-editor/exposure.svg b/assets/icons/action-editor/exposure.svg new file mode 100644 index 00000000000..0fc080b036f --- /dev/null +++ b/assets/icons/action-editor/exposure.svg @@ -0,0 +1,4 @@ + + Exposure + + diff --git a/assets/icons/action-editor/generate.svg b/assets/icons/action-editor/generate.svg new file mode 100644 index 00000000000..ae272e6354a --- /dev/null +++ b/assets/icons/action-editor/generate.svg @@ -0,0 +1,4 @@ + + Generate + + diff --git a/assets/icons/action-editor/generative-effects.svg b/assets/icons/action-editor/generative-effects.svg new file mode 100644 index 00000000000..bb090d52ce3 --- /dev/null +++ b/assets/icons/action-editor/generative-effects.svg @@ -0,0 +1,4 @@ + + Generated layer effects + + diff --git a/assets/icons/action-editor/grid.svg b/assets/icons/action-editor/grid.svg new file mode 100644 index 00000000000..d9c90102321 --- /dev/null +++ b/assets/icons/action-editor/grid.svg @@ -0,0 +1,4 @@ + + Show grid + + diff --git a/assets/icons/action-editor/harmonize.svg b/assets/icons/action-editor/harmonize.svg new file mode 100644 index 00000000000..148897245d2 --- /dev/null +++ b/assets/icons/action-editor/harmonize.svg @@ -0,0 +1,4 @@ + + Harmonize layers + + diff --git a/assets/icons/action-editor/hue.svg b/assets/icons/action-editor/hue.svg new file mode 100644 index 00000000000..9a3032805a1 --- /dev/null +++ b/assets/icons/action-editor/hue.svg @@ -0,0 +1,4 @@ + + Hue and saturation + + diff --git a/assets/icons/action-editor/layer-effects.svg b/assets/icons/action-editor/layer-effects.svg new file mode 100644 index 00000000000..b7ac70b7715 --- /dev/null +++ b/assets/icons/action-editor/layer-effects.svg @@ -0,0 +1,4 @@ + + Layer effects + + diff --git a/assets/icons/action-editor/layers.svg b/assets/icons/action-editor/layers.svg new file mode 100644 index 00000000000..80931627b6d --- /dev/null +++ b/assets/icons/action-editor/layers.svg @@ -0,0 +1,4 @@ + + Layers + + diff --git a/assets/icons/action-editor/lut.svg b/assets/icons/action-editor/lut.svg new file mode 100644 index 00000000000..88061b864a3 --- /dev/null +++ b/assets/icons/action-editor/lut.svg @@ -0,0 +1,4 @@ + + Colour lookup tables + + diff --git a/assets/icons/action-editor/merge.svg b/assets/icons/action-editor/merge.svg new file mode 100644 index 00000000000..b835f376af4 --- /dev/null +++ b/assets/icons/action-editor/merge.svg @@ -0,0 +1,4 @@ + + Merge layers + + diff --git a/assets/icons/action-editor/object-select.svg b/assets/icons/action-editor/object-select.svg new file mode 100644 index 00000000000..65f95b1f24b --- /dev/null +++ b/assets/icons/action-editor/object-select.svg @@ -0,0 +1,4 @@ + + Object selection + + diff --git a/assets/icons/action-editor/remove-background.svg b/assets/icons/action-editor/remove-background.svg new file mode 100644 index 00000000000..5941835d53b --- /dev/null +++ b/assets/icons/action-editor/remove-background.svg @@ -0,0 +1,4 @@ + + Remove background + + diff --git a/assets/icons/action-editor/sharpen.svg b/assets/icons/action-editor/sharpen.svg new file mode 100644 index 00000000000..d894e81f983 --- /dev/null +++ b/assets/icons/action-editor/sharpen.svg @@ -0,0 +1,4 @@ + + Sharpen + + diff --git a/assets/icons/action-editor/smart-remove.svg b/assets/icons/action-editor/smart-remove.svg new file mode 100644 index 00000000000..ede1ca0a372 --- /dev/null +++ b/assets/icons/action-editor/smart-remove.svg @@ -0,0 +1,4 @@ + + Smart removal + + diff --git a/assets/icons/action-editor/styles.svg b/assets/icons/action-editor/styles.svg new file mode 100644 index 00000000000..85d6fc133bb --- /dev/null +++ b/assets/icons/action-editor/styles.svg @@ -0,0 +1,4 @@ + + Generated styles + + diff --git a/assets/icons/action-editor/subject.svg b/assets/icons/action-editor/subject.svg new file mode 100644 index 00000000000..aba8e45a87c --- /dev/null +++ b/assets/icons/action-editor/subject.svg @@ -0,0 +1,4 @@ + + Select subject + + diff --git a/assets/icons/action-editor/text-color.svg b/assets/icons/action-editor/text-color.svg new file mode 100644 index 00000000000..e599c9ae7fc --- /dev/null +++ b/assets/icons/action-editor/text-color.svg @@ -0,0 +1,4 @@ + + Text colour + + diff --git a/assets/icons/action-editor/text.svg b/assets/icons/action-editor/text.svg new file mode 100644 index 00000000000..0c3bdf6b939 --- /dev/null +++ b/assets/icons/action-editor/text.svg @@ -0,0 +1,4 @@ + + Text + + diff --git a/assets/icons/action-editor/wand.svg b/assets/icons/action-editor/wand.svg new file mode 100644 index 00000000000..92e3faabece --- /dev/null +++ b/assets/icons/action-editor/wand.svg @@ -0,0 +1,4 @@ + + Magic wand + + diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index a4bf85c0dea..32c3edb8bed 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -46,6 +46,9 @@ pub struct Action { /// Raw editor glyph identifier; empty uses the default glyph. Keep unknown identifiers verbatim. #[serde(default, skip_serializing_if = "String::is_empty")] pub icon: String, + /// Opaque imported sidecar data, moved with this action. The ATN library codec owns durability. + #[serde(skip)] + pub atn_metadata: Option, #[serde(default)] pub steps: Vec<(String, Value)>, /// Indices of steps excluded from playback (unchecked in the Actions panel). @@ -94,6 +97,7 @@ impl Action { name: name.into(), description: String::new(), icon: String::new(), + atn_metadata: None, steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), @@ -140,6 +144,8 @@ pub struct ActionState { pub held_playback: Option, #[serde(skip)] pub playback_nonce: u64, + #[serde(skip)] + pub playback_yielding: bool, /// Journal length when recording started, and the action index being recorded into. #[serde(skip)] pub recording: Option<(usize, usize)>, @@ -164,6 +170,9 @@ pub struct ActionState { /// an empty one isn't remembered past the session. #[serde(skip)] pub sets: Vec, + /// Opaque metadata bound to actual set rows, including metadata-bearing empty sets. + #[serde(skip)] + pub(crate) atn_set_rows: Vec, /// Bumped whenever `list` changes, so the preset store can skip unchanged writes. #[serde(skip)] pub rev: u64, @@ -728,6 +737,30 @@ pub fn moved_index(index: usize, from: usize, to: usize) -> usize { fn delete(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.delete")?; + if p.get("set").is_some() { + if p.get("action").is_some() || p.get("step").is_some() { + return Err(bad("actions.delete", "select either a set or an action/step")); + } + let set = p.get("set").and_then(Value::as_str).ok_or_else(|| bad("actions.delete", "`set` must be a set name"))?; + if !crate::actions_edit_cmds::set_names(s).iter().any(|name| name == set) { + return Err(bad("actions.delete", "the selected set no longer exists")); + } + if s.actions.recording_active() { + return Err(bad("actions.delete", "stop recording before deleting a set")); + } + let set = set.to_string(); + let removed: Vec<_> = s + .actions + .list + .iter() + .enumerate() + .filter_map(|(index, action)| (crate::atn_cmds::logical_set_name(action.set.as_deref()) == set).then_some(index)) + .collect(); + let names = remove_actions(s, &removed); + crate::atn_cmds::sidecar::retire_sets(&mut s.actions, &std::collections::HashSet::from([set.clone()])); + s.actions.touch(); + return Ok(json!({"deletedSet": set, "deleted": names})); + } let idx = resolve(&s.actions.list, p, "actions.delete")?; if let Some(step) = p.get("step") { let step = step.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("actions.delete", "step must be a non-negative integer"))?; @@ -755,19 +788,50 @@ fn delete(s: &mut Session, p: &Value) -> Result { s.actions.touch(); return Ok(result); } - if s.actions.recording.is_some() { + if s.actions.recording_active() { return Err(bad("actions.delete", "stop recording before deleting an action")); } - let name = s.actions.list.remove(idx).name; + let names = remove_actions(s, &[idx]); s.actions.touch(); - // Its function key goes with it, or the next action given the same name would inherit it. - let key = format!("{PLAY_SHORTCUT_PREFIX}{name}"); - if s.prefs().shortcuts.contains_key(&key) { + Ok(json!({"deleted": names.first()})) +} + +/// Delete validated, sorted library indices as one edit. References follow surviving +/// identities; calls to deleted targets become invalid rather than playing a replacement. +/// Embedded libraries in other commands' JSON own their own references and stay untouched. +fn remove_actions(s: &mut Session, removed: &[usize]) -> Vec { + let old_len = s.actions.list.len(); + let names: Vec<_> = removed.iter().filter_map(|&index| s.actions.list.get(index).map(|action| action.name.clone())).collect(); + for (id, params) in s.actions.list.iter_mut().flat_map(|action| &mut action.steps) { + for &key in call_keys(id) { + let Some(value) = params.get(key) else { continue }; + if value.as_str().is_some_and(|name| names.iter().any(|deleted| deleted == name)) { + params[key] = Value::Null; + } else if let Some(index) = num(value).and_then(|index| usize::try_from(index).ok()).filter(|&index| index < old_len) { + match removed.binary_search(&index) { + Ok(_) => params[key] = Value::Null, + Err(0) => {} + Err(before) => params[key] = json!(index - before), + } + } + } + } + let mut index = 0; + s.actions.list.retain(|_| { + let keep = removed.binary_search(&index).is_err(); + index += 1; + keep + }); + let keys: Vec<_> = names.iter().map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect(); + if keys.iter().any(|key| s.prefs().shortcuts.contains_key(key) || s.prefs().alternate_shortcuts.contains_key(key)) { s.edit_prefs(|prefs| { - prefs.shortcuts.remove(&key); + for key in &keys { + prefs.shortcuts.remove(key); + prefs.alternate_shortcuts.remove(key); + } }); } - Ok(json!({"deleted": name})) + names } /// The Actions panel check marks. With `step`, switch one step on or off; without it, switch @@ -950,7 +1014,7 @@ pub fn specs() -> Vec { label: "Delete Action", menu: &[], shortcut: None, - params: r##"{"action":name|index, "step":index?} → {deleted:name} or {action, deletedStep, steps:count}. step is 0-based, including pending recorded steps. Whole-action deletion is refused while recording."##, + params: r##"{"action":name|index, "step":index?} or {"set":name} → {deleted:name} or {action,deletedStep,steps} or {deletedSet,deleted:[name…]}. Step is 0-based, including pending recorded steps. Deleting a folder also deletes its contents, including an empty folder. Whole-action/folder deletion requires recording to be stopped. Calls to survivors keep their target; calls to deleted actions become invalid."##, enabled: always, run: delete, journal: false, diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index b629e4032a0..9ec48a740c3 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -29,6 +29,13 @@ pub struct Draft { pub(super) recording: Option, } +impl Draft { + /// Runtime identity for the shell's owned page; never a persisted library identity. + pub fn id(&self) -> u64 { + self.id + } +} + /// Only action bindings participate in the editor's shortcut stale guard. fn action_keys(keys: &BTreeMap) -> BTreeMap { keys.iter().filter(|(key, _)| key.starts_with(super::PLAY_SHORTCUT_PREFIX)).map(|(key, value)| (key.clone(), value.clone())).collect() @@ -159,6 +166,12 @@ fn get(s: &mut Session, p: &Value) -> Result { Ok(view(draft, &visible(s, draft)?)) } +/// Read an owned page without executing a command or changing floating-selection state. +pub fn inspect(s: &Session, id: u64) -> Result { + let draft = s.actions.editor.as_ref().filter(|draft| draft.id == id).ok_or_else(|| bad(GET, "the editor no longer owns this draft"))?; + Ok(view(draft, &visible(s, draft)?)) +} + /// Only deliberately edited shortcuts are normalized; imported unrelated strings remain intact. fn shortcut(text: &str, old: &str) -> Result { if text == old || text.is_empty() { @@ -183,6 +196,7 @@ fn update(s: &mut Session, p: &Value) -> Result { if let Some(value) = p.get("icon") { // An unknown identifier is retained metadata; the picker can use a neutral display fallback. next.value.icon = value.as_str().ok_or_else(|| bad(UPDATE, "`icon` must be a string"))?.to_string(); + crate::atn_cmds::sidecar::explicit_icon(&mut next.value); } if let Some(value) = p.get("color") { let color = value.as_str().ok_or_else(|| bad(UPDATE, "`color` must be a string"))?; diff --git a/crates/engine/src/cmd/actions_cmds/playback.rs b/crates/engine/src/cmd/actions_cmds/playback.rs index d4a276471d5..53fc59b4a0e 100644 --- a/crates/engine/src/cmd/actions_cmds/playback.rs +++ b/crates/engine/src/cmd/actions_cmds/playback.rs @@ -28,6 +28,9 @@ fn classic_index(s: &Session, action: &Action) -> Option { /// Definition edits cannot invalidate a shell-owned capture, even through direct Session calls. pub fn admit(s: &Session, id: &str) -> Result<()> { + if s.actions.held_playback.is_some() && s.actions.playing == 0 && s.actions.playback_yielding { + return Err(bad(id, "wait for action playback to finish or cancel it first")); + } if s.actions.held_playback.is_some() && s.actions.playing == 0 && id.starts_with("actions.") @@ -38,11 +41,11 @@ pub fn admit(s: &Session, id: &str) -> Result<()> { Ok(()) } -fn suspend(s: &mut Session, preview: Option<(u64, Arc)>, call: Option<(String, usize)>) -> Result> { +fn suspend(s: &mut Session, preview: Option<(u64, Arc)>, call: Option<(String, usize)>, required: bool) -> Result> { if s.actions.held_playback.is_some() { return Err(bad("actions.play", "another action owns a suspended playback")); } - if preview.is_none() && !s.actions.recording_active() { + if !required && preview.is_none() && !s.actions.recording_active() { return Ok(None); } // A job started in the old capture must land before its cursor is moved past preview work. @@ -76,7 +79,7 @@ fn suspend(s: &mut Session, preview: Option<(u64, Arc)>, call: Option<(S pub fn library(s: &mut Session, params: &Value) -> Result<(Arc, usize, Option)> { admit(s, "actions.play")?; let (action, from) = super::playback_plan(s, params)?; - let owner = suspend(s, None, Some((action.name.clone(), from)))?; + let owner = suspend(s, None, Some((action.name.clone(), from)), false)?; Ok((Arc::new(action), from, owner)) } @@ -88,11 +91,25 @@ pub fn preview(s: &mut Session, draft: u64, from: usize) -> Result<(Arc, if from > action.steps.len() { return Err(bad("actions.editor.preview", "preview step is outside the draft")); } - let owner = suspend(s, Some((draft, action.clone())), None)?.ok_or_else(|| bad("actions.editor.preview", "preview ownership is unavailable"))?; + let owner = suspend(s, Some((draft, action.clone())), None, true)?.ok_or_else(|| bad("actions.editor.preview", "preview ownership is unavailable"))?; Ok((action, owner)) } impl Owner { + /// A frame-spanning library call needs the same lease even without an active recorder. + pub fn library(s: &mut Session, params: &Value) -> Result<(Arc, usize, Self)> { + admit(s, "actions.play")?; + let (action, from) = super::playback_plan(s, params)?; + let owner = suspend(s, None, Some((action.name.clone(), from)), true)?.ok_or_else(|| bad("actions.play", "playback ownership is unavailable"))?; + Ok((Arc::new(action), from, owner)) + } + + /// Between UI ticks, reject unowned commands before their pre-command effects or jobs. + pub fn yielding(&self, s: &mut Session, yielding: bool) -> Result<()> { + self.validate(s)?; + s.actions.playback_yielding = yielding; + Ok(()) + } pub fn is_preview(&self) -> bool { self.preview.is_some() } @@ -129,6 +146,7 @@ impl Owner { return; } s.actions.held_playback = None; + s.actions.playback_yielding = false; if s.actions.take != self.take || s.actions.recording_active() { return; } diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index ef13d5ad155..d779b91592d 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; -use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve}; +use crate::actions_cmds::{Action, flush_recording, not_playing, pending_steps, resolve}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; @@ -66,14 +66,126 @@ fn insert(s: &mut Session, p: &Value) -> Result { if s.actions.recording.is_some_and(|(_, i)| i == idx) { flush_recording(s)?; } + Ok(insert_step(s, idx, at, (id, params))) +} + +/// Insert an already validated step without normalizing a copied step's payload. +fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value { let action = &mut s.actions.list[idx]; - action.steps.insert(at, (id.clone(), params)); + let id = step.0.clone(); + action.steps.insert(at, step); action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()}); s.actions.touch(); + result +} + +/// Keep session-list Play Action targets stable when a copied action inserts a row. +/// Other commands' nested JSON may own a separate action library and is not traversed. +fn shift_action_calls(actions: &mut [Action], at: usize, count: usize) { + for (id, params) in actions.iter_mut().flat_map(|a| &mut a.steps) { + if id != "actions.play" { + continue; + } + let Some(Value::Number(n)) = params.get("action") else { continue }; + let shifted = if let Some(n) = n.as_u64() { + if n >= at as u64 { n.checked_add(count as u64).map(Value::from) } else { None } + } else { + // `actions.play` also accepts floating indices, rounding them before lookup. + n.as_f64().filter(|n| n.round() >= at as f64).map(|n| Value::from(n + count as f64)) + }; + if let Some(value) = shifted { + params["action"] = value; + } + } +} + +/// Duplicate a step or action below its source, preserving settings and per-step flags. +fn duplicate(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + not_playing(s, cmd)?; + if p.get("set").is_some() { + return duplicate_set(s, p); + } + let idx = resolve(&s.actions.list, p, cmd)?; + if let Some(step) = index(p, "step", cmd)? { + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + if step >= len { + return Err(bad(cmd, "step is out of range")); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &s.actions.list[idx]; + let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?; + let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step)); + let at = step + 1; + let mut result = insert_step(s, idx, at, copied); + let action = &mut s.actions.list[idx]; + if disabled { + action.disabled.insert(at); + } + if dialog { + action.dialogs.insert(at); + } + result["index"] = json!(idx); + return Ok(result); + } + if s.actions.recording_active() { + return Err(bad(cmd, "stop recording before duplicating an action")); + } + let mut copied = s.actions.list[idx].clone(); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + copied.name = names.claim(&format!("{} copy", copied.name)); + let at = idx + 1; + let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); + s.actions.list.insert(at, copied); + shift_action_calls(&mut s.actions.list, at, 1); + s.actions.touch(); Ok(result) } +/// Copy a folder below its source, using the existing first-appearance order and empty-set model. +fn duplicate_set(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.duplicate"; + if p.get("action").is_some() || p.get("step").is_some() { + return Err(bad(cmd, "select either a set or an action/step")); + } + if s.actions.recording_active() { + return Err(bad(cmd, "stop recording before duplicating a set")); + } + let source = p.get("set").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`set` must be a set name"))?; + let sets = set_names(s); + if !sets.iter().any(|n| n == source) { + return Err(bad(cmd, "the selected set no longer exists")); + } + let mut set_names = crate::atn_cmds::UniqueNames::new(sets); + let name = set_names.claim(&format!("{source} copy")); + let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + let at = s.actions.list.iter().position(|a| crate::atn_cmds::logical_set_name(a.set.as_deref()) == source).map_or(s.actions.list.len(), |i| i + 1); + let added: Vec<_> = s + .actions + .list + .iter() + .filter(|a| crate::atn_cmds::logical_set_name(a.set.as_deref()) == source) + .cloned() + .map(|mut a| { + a.name = names.claim(&format!("{} copy", a.name)); + a.set = Some(name.clone()); + a + }) + .collect(); + let copied: Vec<_> = added.iter().map(|a| a.name.clone()).collect(); + let count = added.len(); + s.actions.list.splice(at..at, added); + if count != 0 { + shift_action_calls(&mut s.actions.list, at, count); + } + crate::atn_cmds::sidecar::duplicate_set_rows(&mut s.actions, source, &name); + s.actions.touch(); + Ok(json!({"set":name,"actions":copied,"index":at})) +} + /// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without /// it, every step (all on unless all already are, like clicking the action's own toggle). fn dialog(s: &mut Session, p: &Value) -> Result { @@ -120,6 +232,7 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { Some(Value::String(_)) => return Err(bad(cmd, "`set` is empty; pass null to take the action out of its set")), Some(_) => return Err(bad(cmd, "`set` must be a name or null")), }; + crate::atn_cmds::sidecar::assign_set_metadata(&mut s.actions, idx, set.as_deref()); let action = &mut s.actions.list[idx]; action.set = set; let result = json!({"action": action.name, "set": action.set}); @@ -131,7 +244,14 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { /// then the empty ones New Set made. pub fn set_names(s: &Session) -> Vec { let mut seen = std::collections::HashSet::new(); - s.actions.list.iter().filter_map(|a| a.set.as_ref()).chain(&s.actions.sets).filter(|set| seen.insert(set.as_str())).cloned().collect() + s.actions + .list + .iter() + .map(|action| crate::atn_cmds::logical_set_name(action.set.as_deref())) + .chain(s.actions.sets.iter().map(String::as_str)) + .filter(|set| seen.insert(*set)) + .map(str::to_string) + .collect() } /// New Set…: an empty set folder, named `name` or "Set N" (the first number no set has), that New @@ -273,6 +393,16 @@ fn conditional(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + CommandSpec { + id: "actions.duplicate", + label: "Duplicate Action", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "step":index?} or {"set":name} → {action,index,steps} or {set,actions,index}. Copies a step, action or entire folder below its source, retaining settings and flags. Whole-action/folder copies require recording to be stopped; original function keys stay with their original actions."##, + enabled: always, + run: duplicate, + journal: false, + }, CommandSpec { id: "actions.insert", label: "Insert Step", diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 47f4b688fbc..33304ea1bb4 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -28,10 +28,13 @@ use crate::{EngineError, Result, Session}; mod adjustments; mod convert; +pub(crate) use convert::UniqueNames; mod descriptors; mod events; mod fields; mod files; +pub(crate) mod sidecar; +pub use sidecar::ActionMetadata; mod steps_channels; mod steps_distort; @@ -50,7 +53,7 @@ mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; -pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; +pub use convert::{Imported, ImportedSet, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; #[cfg(test)] @@ -82,6 +85,18 @@ pub const PHOTOSHOP_STEP: &str = "actions.photoshopStep"; pub const STOP_MESSAGE: &str = "actions.stopMessage"; /// The set name Save Actions uses for actions that came from no set. pub const DEFAULT_SET: &str = "PhotoCraft Actions"; +/// One logical outside-group category shared by the classic and modern Actions views. +pub const UNGROUPED_SET: &str = "Ungrouped Actions"; + +/// The single visible category for legacy ungrouped actions and empty-wire imports. +pub fn logical_set_name(set: Option<&str>) -> &str { + set.filter(|name| *name != UNGROUPED_SET).unwrap_or(UNGROUPED_SET) +} + +/// The ungrouped category exports with an empty ATN set name; explicit names stay intact. +pub fn wire_set_name(set: &str) -> &str { + if set == UNGROUPED_SET { "" } else { set } +} fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } @@ -113,7 +128,7 @@ pub fn specs() -> Vec { label: "Load Actions…", menu: &[], shortcut: None, - params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name),"encoding":"auto"|label="auto" (the encoding of 8-bit step names: shift_jis, gbk, big5, euc-kr, windows-1252, macintosh…; auto works it out)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings, encoding}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##, + params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override only a file with one real set),"replace":bool=true (replace each imported set),"encoding":"auto"|label="auto" (one encoding for all 8-bit step names: shift_jis, gbk, big5, euc-kr, windows-1252, macintosh…)} → {set, sets:[names], actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings, encoding}. All real sets import together. Recognized sidecar metadata follows set/action position; empty wire set names become Ungrouped Actions. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps, retained for Save Actions and reported at playback."##, enabled: always, run: load, journal: false, @@ -123,7 +138,7 @@ pub fn specs() -> Vec { label: "Save Actions…", menu: &[], shortcut: None, - params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions, none for an empty New Set set; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##, + params: r##"{"path":".atn file"? (else base64 `data`),"set":name? (one logical set, including empty sets),"action":name|index? (one action),"skipUnsupported":bool=false (omit steps with no Photoshop equivalent)} → {set, sets:[names], actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}. Default saves every real group; a single named group keeps the legacy PhotoCraft Actions output name. Ungrouped Actions writes an empty wire name. Sidecar metadata is rebuilt for the selected sets/actions; conflicting archive notes or global metadata fail explicitly."##, enabled: always, run: save, journal: false, diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 57ee26a2e52..e167880c1c0 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -1,7 +1,7 @@ use super::descriptors::{desc, get_obj, jstr}; use super::events::{event_and_descriptor, event_for}; use super::fields::Mapped; -use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, D, DEFAULT_SET, Descriptor, PHOTOSHOP_STEP, UnicodeString, Value, atn, json}; +use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, D, Descriptor, PHOTOSHOP_STEP, UnicodeString, Value, atn, json}; use crate::actions_cmds::{color_from_atn, color_to_atn}; // ------------------------------------------------------------------ import @@ -20,6 +20,10 @@ pub struct Unsupported { pub struct Imported { pub set: String, pub actions: Vec, + /// Real physical sets in file order, indexing `actions`; the recognized sidecar is absent. + pub sets: Vec, + /// Function keys by action position, avoiding duplicate-name association during load. + pub action_keys: Vec>, /// Function keys the file assigns, as `(action name, key)`. pub function_keys: Vec<(String, String)>, pub unsupported: Vec, @@ -29,6 +33,16 @@ pub struct Imported { pub encoding: Option, } +/// One real physical set and its positional range in the converted action list. +#[derive(Clone, Debug)] +pub struct ImportedSet { + /// Logical display name after the reserved empty-wire category rule. + pub name: String, + /// This physical set's actions before session collision renaming. + pub actions: std::ops::Range, + pub(crate) metadata: Option>, +} + fn photoshop_step(item: &AtnItem, label: String) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); let event_kind = match &item.event { @@ -65,16 +79,21 @@ pub(super) fn clip_name(s: &str) -> String { s.chars().take(MAX_NAME_CHARS).collect() } -fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { +fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Vec, total: &mut usize) -> std::result::Result { let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); action.color = color_from_atn(a.color); for (i, item) in a.items.iter().enumerate() { - let mapped = match (event_for(&item.event), &item.descriptor) { - (Some(e), Some(d)) => (e.from)(d), - (Some(e), None) => (e.from)(&Descriptor::default()), - (None, _) => Mapped::Unsupported { harmless: false }, + let mapped = if super::sidecar::has_payload(item) { + // An unrecognized metadata-bearing Stop stays visible/opaque, never silently loses its payload. + Mapped::Unsupported { harmless: false } + } else { + match (event_for(&item.event), &item.descriptor) { + (Some(e), Some(d)) => (e.from)(d), + (Some(e), None) => (e.from)(&Descriptor::default()), + (None, _) => Mapped::Unsupported { harmless: false }, + } }; let (new_steps, enabled) = match mapped { Mapped::Steps(mut s) => { @@ -88,6 +107,10 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve } }; for mut s in new_steps { + *total = total.checked_add(1).filter(|total| *total <= atn::MAX_TOTAL_ITEMS).ok_or("too many converted steps in actions archive")?; + if action.steps.len() >= atn::MAX_ITEMS { + return Err("too many converted steps in one action".into()); + } normalise(&mut s.1); let idx = action.steps.len(); action.steps.push(s); @@ -99,7 +122,7 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve } } } - action + Ok(action) } /// Convert a `.atn` file. Fails only when the file is unreadable: every step imports, as a @@ -111,16 +134,55 @@ pub fn import(bytes: &[u8]) -> std::result::Result { /// [`import`], reading 8-bit step names in `encoding` (a label such as `"shift_jis"` or /// `"gbk"`) instead of working it out. An unknown label fails. pub fn import_with_encoding(bytes: &[u8], encoding: Option<&str>) -> std::result::Result { - let file = atn::parse_with_encoding(bytes, encoding).map_err(|e| e.to_string())?; - let set = file.name.to_string_lossy(); - let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { clip_name(&set) }; - let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), encoding: file.encoding.clone(), ..Default::default() }; - for a in &file.actions { - let action = convert_action(&file, a, &set, &mut out.unsupported); - if a.function_key > 0 { - out.function_keys.push((action.name.clone(), function_key_name(a.function_key, a.shift, a.command))); + let files = atn::parse_sets_with_encoding(bytes, encoding).map_err(|error| error.to_string())?; + let mut metadata = super::sidecar::decode(&files)?; + let mut out = Imported::default(); + let mut total = 0usize; + let reserved = std::iter::once(super::UNGROUPED_SET.to_string()).chain( + files + .iter() + .enumerate() + .filter(|(index, _)| metadata.hidden != Some(*index)) + .map(|(_, file)| clip_name(&file.name.to_string_lossy())) + .filter(|name| !name.is_empty() && name != super::UNGROUPED_SET), + ); + let mut set_names = UniqueNames::new(reserved); + for (index, file) in files.iter().enumerate() { + out.warnings.extend(file.warnings.iter().cloned()); + if out.encoding.is_none() { + out.encoding = file.encoding.clone(); } - out.actions.push(action); + if metadata.hidden == Some(index) { + continue; + } + let set = file.name.to_string_lossy(); + let set = if set.is_empty() { + super::UNGROUPED_SET.to_string() + } else if set == super::UNGROUPED_SET { + let renamed = set_names.claim(&set); + out.warnings.push(format!("set `{set}` imported as `{renamed}` to keep the ungrouped category distinct")); + renamed + } else { + clip_name(&set) + }; + if out.sets.is_empty() { + out.set = set.clone(); + } + let start = out.actions.len(); + for (at, action) in file.actions.iter().enumerate() { + let mut converted = convert_action(file, action, &set, &mut out.unsupported, &mut total)?; + if let Some(value) = metadata.actions.get_mut(index).and_then(|actions| actions.get_mut(at)).and_then(Option::take) { + super::sidecar::apply(&mut converted, value); + } + let key = (action.function_key > 0).then(|| function_key_name(action.function_key, action.shift, action.command)); + if let Some(key) = &key { + out.function_keys.push((converted.name.clone(), key.clone())); + } + out.action_keys.push(key); + out.actions.push(converted); + } + let source = metadata.sets.get_mut(index).and_then(Option::take); + out.sets.push(ImportedSet { name: set, actions: start..out.actions.len(), metadata: source }); } Ok(out) } @@ -225,14 +287,59 @@ pub fn export( function_keys: &[(String, String)], skip_unsupported: bool, ) -> std::result::Result<(Vec, Vec), String> { - let mut skipped = Vec::new(); - let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new(), ..Default::default() }; - for a in actions { - // Until sidecar writing is implemented, never silently discard editor metadata. - // skipUnsupported applies to steps, not the action's description or icon. - if !a.description.is_empty() || !a.icon.is_empty() { - return Err(format!("action `{}` has editor metadata that requires ATN sidecar support", a.name)); + if actions.len() > atn::MAX_TOTAL_ACTIONS { + return Err("too many actions in actions archive".into()); + } + let selection = super::sidecar::Selection { actions: actions.to_vec(), source: super::sidecar::source_for(actions)? }; + export_groups(&[(set, selection)], function_keys, skip_unsupported) +} + +pub(super) fn export_groups( + groups: &[(&str, super::sidecar::Selection<'_>)], + function_keys: &[(String, String)], + skip_unsupported: bool, +) -> std::result::Result<(Vec, Vec), String> { + if groups.is_empty() || groups.len() > atn::MAX_SETS { + return Err("invalid number of sets in actions archive".into()); + } + let mut actions = 0usize; + let mut steps = 0usize; + for (_, group) in groups { + if group.actions.len() > atn::MAX_ACTIONS { + return Err("too many actions in one set".into()); } + actions = actions.checked_add(group.actions.len()).filter(|count| *count <= atn::MAX_TOTAL_ACTIONS).ok_or("too many actions in actions archive")?; + for action in &group.actions { + if action.steps.len() > atn::MAX_ITEMS { + return Err("too many steps in one action".into()); + } + steps = steps.checked_add(action.steps.len()).filter(|count| *count <= atn::MAX_TOTAL_ITEMS).ok_or("too many steps in actions archive")?; + } + } + let mut skipped = Vec::new(); + let mut files = Vec::with_capacity(groups.len().min(atn::MAX_SETS)); + let mut written = 0usize; + for (set, selection) in groups { + files.push(export_set(set, &selection.actions, function_keys, skip_unsupported, &mut skipped, &mut written)?); + } + let selected: Vec<_> = + groups.iter().map(|(_, selection)| super::sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone() }).collect(); + super::sidecar::append(&mut files, &selected)?; + let bytes = atn::write_sets(&files).map_err(|error| error.to_string())?; + Ok((bytes, skipped)) +} + +fn export_set( + set: &str, + actions: &[&Action], + function_keys: &[(String, String)], + skip_unsupported: bool, + skipped: &mut Vec, + written: &mut usize, +) -> std::result::Result { + let mut file = + AtnFile { name: UnicodeString::new_nul(super::wire_set_name(set)), expanded: true, actions: Vec::new(), warnings: Vec::new(), ..Default::default() }; + for a in actions { let color = color_to_atn(&a.color).ok_or_else(|| format!("action `{}` has an invalid colour", a.name))?; let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, color, ..Default::default() }; if let Some((n, shift, command)) = function_keys.iter().find(|(n, _)| n == &a.name).and_then(|(_, key)| parse_function_key(key)) { @@ -255,6 +362,7 @@ pub fn export( )); }; for mut item in items { + *written = written.checked_add(1).filter(|count| *count <= atn::MAX_TOTAL_ITEMS).ok_or("too many output steps in actions archive")?; item.enabled = a.step_enabled(i); item.with_dialog = a.dialogs.contains(&i); item.dialog_options = if item.with_dialog { 2 } else { 0 }; @@ -263,8 +371,7 @@ pub fn export( } file.actions.push(out); } - let bytes = atn::write(&file).map_err(|e| e.to_string())?; - Ok((bytes, skipped)) + Ok(file) } // ------------------------------------------------------------------ commands @@ -272,18 +379,18 @@ pub fn export( /// Assigns names not already used, as `name`, `name 2`, `name 3`… A plain linear scan of the /// taken list per candidate is cubic when an action file repeats one base name many times, so /// membership uses a set and the next suffix to try is cached per base name. -pub(super) struct UniqueNames { +pub(crate) struct UniqueNames { taken: std::collections::HashSet, next_suffix: std::collections::HashMap, } impl UniqueNames { - pub(super) fn new>(existing: I) -> Self { + pub(crate) fn new>(existing: I) -> Self { UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() } } /// Reserves and returns a unique name derived from `name`, recording it as taken. - pub(super) fn claim(&mut self, name: &str) -> String { + pub(crate) fn claim(&mut self, name: &str) -> String { if self.taken.insert(name.to_string()) { return name.to_string(); } diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 0971bd0d868..562fa6dac4d 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -1,5 +1,6 @@ -use super::convert::{UniqueNames, export, import_with_encoding}; -use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json}; +use super::convert::{export_groups, import_with_encoding}; +use super::sidecar; +use super::{Action, DEFAULT_SET, EngineError, Result, Session, UniqueNames, Value, bad, json}; pub(super) fn load(s: &mut Session, p: &Value) -> Result { let cmd = "actions.load"; @@ -17,7 +18,13 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { match p.get("set") { None | Some(Value::Null) => {} Some(Value::String(n)) if !n.trim().is_empty() => { + if imp.sets.len() != 1 { + return Err(bad(cmd, "set override requires exactly one real set")); + } imp.set = n.trim().to_string(); + if let Some(set) = imp.sets.first_mut() { + set.name = imp.set.clone(); + } for a in &mut imp.actions { a.set = Some(imp.set.clone()); } @@ -29,28 +36,90 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { Some(Value::Bool(b)) => *b, Some(_) => return Err(bad(cmd, "`replace` must be true or false")), }; - if replace { - s.actions.list.retain(|a| a.set.as_deref() != Some(imp.set.as_str())); + let targets: std::collections::HashSet = imp.sets.iter().map(|set| set.name.clone()).collect(); + let keep = |action: &&Action| !replace || !targets.contains(super::logical_set_name(action.set.as_deref())); + let retained = s.actions.list.iter().filter(keep).count(); + if retained.checked_add(imp.actions.len()).is_none_or(|count| count > super::atn::MAX_TOTAL_ACTIONS) { + return Err(bad(cmd, "combined action library exceeds the action limit")); } - let mut taken = UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); + let mut total_steps = 0usize; + for action in s.actions.list.iter().filter(keep).chain(imp.actions.iter()) { + total_steps = total_steps + .checked_add(action.steps.len()) + .filter(|count| *count <= super::atn::MAX_TOTAL_ITEMS) + .ok_or_else(|| bad(cmd, "combined action library exceeds the step limit"))?; + } + let old_groups = sidecar::groups(&s.actions, None).map_err(|error| bad(cmd, error))?; + let retained_groups: Vec<_> = old_groups.iter().filter(|(name, _)| !replace || !targets.contains(*name)).collect(); + let sidecar_needed = imp.sets.iter().any(|set| set.metadata.is_some()) + || retained_groups.iter().any(|(_, selection)| { + selection.source.is_some() + || selection.actions.iter().any(|action| action.atn_metadata.is_some() || !action.description.is_empty() || !action.icon.is_empty()) + }); + let overhead = usize::from(sidecar_needed); + if retained_groups.len().checked_add(imp.sets.len()).and_then(|count| count.checked_add(overhead)).is_none_or(|count| count > super::atn::MAX_SETS) { + return Err(bad(cmd, "combined action library exceeds the set limit")); + } + if retained.checked_add(imp.actions.len()).and_then(|count| count.checked_add(overhead)).is_none_or(|count| count > super::atn::MAX_TOTAL_ACTIONS) + || total_steps.checked_add(overhead).is_none_or(|count| count > super::atn::MAX_TOTAL_ITEMS) + { + return Err(bad(cmd, "combined action library leaves no space for its metadata sidecar")); + } + drop(retained_groups); + drop(old_groups); + let previous_rows: std::collections::HashSet = s.actions.sets.iter().filter(|name| targets.contains(*name)).cloned().collect(); + let old_rows = s.actions.sets.iter().filter(|name| !replace || !targets.contains(*name)).count(); + let new_rows = if imp.sets.iter().any(|set| set.metadata.is_some()) { + imp.sets.len() + } else { + imp.sets + .iter() + .filter(|set| set.actions.is_empty() || previous_rows.contains(&set.name)) + .map(|set| set.name.as_str()) + .collect::>() + .len() + }; + if old_rows.checked_add(new_rows).is_none_or(|count| count > super::atn::MAX_SETS) { + return Err(bad(cmd, "too many action set rows")); + } + let mut set_for = vec![0usize; imp.actions.len()]; + for (index, set) in imp.sets.iter().enumerate() { + set_for.get_mut(set.actions.clone()).ok_or_else(|| bad(cmd, "invalid imported set range"))?.fill(index); + } + if imp.action_keys.len() != imp.actions.len() { + return Err(bad(cmd, "invalid imported shortcut positions")); + } + let mut taken = UniqueNames::new(s.actions.list.iter().filter(keep).map(|action| action.name.clone())); let mut names = Vec::with_capacity(imp.actions.len()); - // The file's name of each action that had to be renamed, and its new name. - let mut renamed: std::collections::HashMap = std::collections::HashMap::new(); + let mut renamed: Vec> = vec![Default::default(); imp.sets.len()]; + let mut global: std::collections::HashMap> = std::collections::HashMap::new(); let mut steps = 0usize; let mut added = Vec::with_capacity(imp.actions.len()); - for mut a in imp.actions { + let mut keys = Vec::new(); + for (index, mut a) in imp.actions.into_iter().enumerate() { let name = taken.claim(&a.name); - if name != a.name { - renamed.entry(a.name.clone()).or_insert_with(|| name.clone()); + let group = set_for.get(index).and_then(|set| renamed.get_mut(*set)).ok_or_else(|| bad(cmd, "invalid imported action position"))?; + group.entry(a.name.clone()).or_insert_with(|| name.clone()); + global + .entry(a.name.clone()) + .and_modify(|entry| { + if entry.as_ref() != Some(&name) { + *entry = None; + } + }) + .or_insert(Some(name.clone())); + if let Some(key) = imp.action_keys.get(index).and_then(Option::as_ref) { + keys.push((name.clone(), key.clone())); } a.name = name.clone(); steps = steps.saturating_add(a.steps.len()); names.push(name); added.push(a); } - // Play and Conditional steps call actions by name: follow the renames in the actions just - // loaded, each target looked up once by the name the file gave it. - for a in &mut added { + // Resolve imported calls within their physical set first; never use another set's first + // duplicate display name for metadata or shortcuts. Ambiguous cross-set calls fail before commit. + for (index, a) in added.iter_mut().enumerate() { + let local = set_for.get(index).and_then(|set| renamed.get(*set)).ok_or_else(|| bad(cmd, "invalid imported action position"))?; for (id, params) in &mut a.steps { let keys: &[&str] = match id.as_str() { "actions.play" => &["action"], @@ -58,19 +127,36 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { _ => &[], }; for key in keys { - if let Some(to) = params.get(*key).and_then(Value::as_str).and_then(|n| renamed.get(n)) { - params[*key] = json!(to); + if let Some(target) = params.get(*key).and_then(Value::as_str) { + let to = if let Some(to) = local.get(target) { + Some(to) + } else { + match global.get(target) { + Some(Some(to)) => Some(to), + Some(None) => return Err(bad(cmd, "ambiguous cross-set action call; source set is required")), + None => None, + } + }; + if let Some(to) = to { + params[*key] = json!(to); + } } } } } + let set_names: Vec = imp.sets.iter().map(|set| set.name.clone()).collect(); + if replace { + s.actions.list.retain(|action| !targets.contains(super::logical_set_name(action.set.as_deref()))); + sidecar::retire_sets(&mut s.actions, &targets); + } s.actions.list.extend(added); - // A set saved with no actions (Photoshop saves an empty set) is an empty folder, like one New - // Set made. - if !s.actions.list.iter().any(|a| a.set.as_deref() == Some(imp.set.as_str())) && !s.actions.sets.contains(&imp.set) { - s.actions.sets.push(imp.set.clone()); + for set in imp.sets { + if set.metadata.is_some() { + sidecar::install_row(&mut s.actions, &set.name, set.metadata); + } else if (set.actions.is_empty() || previous_rows.contains(&set.name)) && !s.actions.sets.contains(&set.name) { + sidecar::install_row(&mut s.actions, &set.name, None); + } } - let keys: Vec<(String, String)> = imp.function_keys.into_iter().map(|(n, k)| (renamed.get(&n).cloned().unwrap_or(n), k)).collect(); if !keys.is_empty() { s.edit_prefs(|prefs| { for (name, key) in &keys { @@ -83,6 +169,7 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { s.actions.touch(); Ok(json!({ "set": imp.set, + "sets": set_names, "actions": names, "steps": steps, "unsupported": imp.unsupported, @@ -108,29 +195,43 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), }; - let (set, actions): (String, Vec<&Action>) = if p.get("action").is_some() { + let mut groups = if p.get("action").is_some() { let idx = crate::actions_cmds::resolve(&s.actions.list, p, cmd)?; let a = &s.actions.list[idx]; - (set_param.or_else(|| a.set.clone()).unwrap_or_else(|| a.name.clone()), vec![a]) - } else if let Some(set) = set_param { - let list: Vec<&Action> = s.actions.list.iter().filter(|a| a.set.as_deref() == Some(set.as_str())).collect(); - // An empty set New Set made is written with no actions, as Photoshop saves one. - if list.is_empty() && !s.actions.sets.contains(&set) { + vec![(set_param.clone().unwrap_or_else(|| super::logical_set_name(a.set.as_deref()).to_string()), sidecar::selection(&s.actions, a))] + } else if let Some(set) = &set_param { + let groups = sidecar::groups(&s.actions, Some(set)).map_err(|error| bad(cmd, error))?; + if groups.is_empty() { return Err(bad(cmd, format!("no actions in a set named `{set}`"))); } - (set, list) + groups.into_iter().map(|(name, selection)| (name.to_string(), selection)).collect() } else { - if s.actions.list.is_empty() { + if s.actions.list.is_empty() && s.actions.sets.is_empty() { return Err(bad(cmd, "there are no actions to save")); } - (DEFAULT_SET.to_string(), s.actions.list.iter().collect()) + sidecar::groups(&s.actions, None).map_err(|error| bad(cmd, error))?.into_iter().map(|(name, selection)| (name.to_string(), selection)).collect() }; - let names: Vec = actions.iter().map(|a| a.name.clone()).collect(); + // Preserve the old default single-set output name; a multi-set Save keeps each real group. + if p.get("action").is_none() && set_param.is_none() && groups.len() == 1 { + if let Some((name, _)) = groups.first_mut() { + if name != super::UNGROUPED_SET { + *name = DEFAULT_SET.to_string(); + } + } + } + let names: Vec = groups.iter().flat_map(|(_, selection)| &selection.actions).map(|action| action.name.clone()).collect(); + let set_names: Vec = groups.iter().map(|(name, _)| name.clone()).collect(); + let set = set_param.or_else(|| set_names.first().cloned()).unwrap_or_else(|| DEFAULT_SET.to_string()); let keys: Vec<(String, String)> = s.prefs().shortcuts.iter().filter_map(|(k, v)| k.strip_prefix(crate::actions_cmds::PLAY_SHORTCUT_PREFIX).map(|n| (n.to_string(), v.clone()))).collect(); - let (bytes, skipped) = export(&set, &actions, &keys, skip).map_err(|e| bad(cmd, e))?; - let steps: usize = actions.iter().map(|a| a.steps.len()).sum::().saturating_sub(skipped.len()); - let mut out = json!({"set": set, "actions": names, "steps": steps, "bytes": bytes.len(), "skipped": skipped}); + let steps: usize = groups.iter().flat_map(|(_, selection)| &selection.actions).map(|action| action.steps.len()).sum(); + let borrowed: Vec<_> = groups + .iter() + .map(|(name, selection)| (name.as_str(), sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone() })) + .collect(); + let (bytes, skipped) = export_groups(&borrowed, &keys, skip).map_err(|error| bad(cmd, error))?; + let steps = steps.saturating_sub(skipped.len()); + let mut out = json!({"set": set, "sets": set_names, "actions": names, "steps": steps, "bytes": bytes.len(), "skipped": skipped}); match p.get("path") { None | Some(Value::Null) => { out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)); diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs new file mode 100644 index 00000000000..0995c1e780b --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -0,0 +1,727 @@ +//! The measured Actions sidecar. Opaque bytes travel with their action, never with its display name. +use std::{ops::Range, sync::Arc}; + +use photocraft_psd::{ + atn, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; + +use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem}; +use crate::actions_cmds::ActionState; + +const MODERN: &str = "modernizedActionData"; +const MAX_BYTES: usize = crate::preset_import_cmds::MAX_FILE_BYTES as usize; +type Result = std::result::Result; + +fn extend(out: &mut Vec, bytes: &[u8]) -> Result<()> { + if out.len().checked_add(bytes.len()).is_none_or(|length| length > MAX_BYTES) { + return Err("sidecar exceeds the action-file byte limit".into()); + } + out.extend_from_slice(bytes); + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RawDescriptor { + source: Arc<[u8]>, + span: Range, +} + +struct Field { + key: Id, + value: D, + span: Range, + key_len: usize, +} + +fn key_len(key: &Id) -> usize { + match key { + Id::Code(_) => 8, + Id::Str(bytes) => 4usize.saturating_add(bytes.len()), + } +} + +fn value_bytes(value: D) -> Result> { + let header = Descriptor::new("null").to_bytes().len(); + let bytes = Descriptor::new("null").with("value", value).to_bytes(); + bytes.get(header.saturating_add(9)..).map(<[u8]>::to_vec).ok_or_else(|| "invalid encoded sidecar value".into()) +} + +impl RawDescriptor { + fn new(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err("sidecar exceeds the action-file byte limit".into()); + } + Descriptor::from_bytes(bytes).map_err(|error| error.to_string())?; + Ok(Self { source: Arc::from(bytes), span: 0..bytes.len() }) + } + + fn bytes(&self) -> &[u8] { + self.source.get(self.span.clone()).unwrap_or(&[]) + } + + fn parsed(&self) -> Result { + Descriptor::from_bytes(self.bytes()).map_err(|error| error.to_string()) + } + + fn fields(&self) -> Result<(usize, Vec)> { + let descriptor = self.parsed()?; + let header = Descriptor { name: descriptor.name.clone(), class_id: descriptor.class_id.clone(), items: Vec::new() }.to_bytes().len(); + let mut cursor = self.span.start.checked_add(header).ok_or("sidecar offset overflow")?; + let mut fields = Vec::with_capacity(descriptor.items.len().min(32)); + for (key, value) in descriptor.items { + // Serialization supplies only the parsed item's LENGTH. Original bytes, including + // unknown bool encodings/NaN payloads, are copied from the source ranges below. + let size = key_len(&key).checked_add(value_bytes(value.clone())?.len()).ok_or("sidecar field length overflow")?; + let end = cursor.checked_add(size).filter(|end| *end <= self.span.end).ok_or("invalid sidecar field span")?; + fields.push(Field { key_len: key_len(&key), key, value, span: cursor..end }); + cursor = end; + } + if cursor != self.span.end { + return Err("sidecar descriptor length changed while locating fields".into()); + } + Ok((header, fields)) + } + + fn list(&self, name: &str) -> Result> { + let (_, fields) = self.fields()?; + let mut matching = fields.into_iter().filter(|field| field.key.is(name)); + let field = matching.next().ok_or_else(|| format!("missing sidecar {name}"))?; + if matching.next().is_some() { + return Err(format!("duplicate sidecar {name}")); + } + let D::List(values) = field.value else { + return Err(format!("sidecar {name} must be a list")); + }; + if values.len() > atn::MAX_TOTAL_ACTIONS { + return Err("too many sidecar list entries".into()); + } + let mut cursor = field.span.start.checked_add(field.key_len).and_then(|offset| offset.checked_add(8)).ok_or("sidecar offset overflow")?; + let mut out = Vec::with_capacity(values.len().min(256)); + for value in values { + let D::Descriptor(descriptor) = value else { + return Err(format!("sidecar {name} must contain objects")); + }; + let start = cursor.checked_add(4).ok_or("sidecar offset overflow")?; + let end = start.checked_add(descriptor.to_bytes().len()).filter(|end| *end <= field.span.end).ok_or("invalid sidecar object span")?; + if self.source.get(cursor..start) != Some(b"Objc") { + return Err("sidecar list object has an unsupported kind".into()); + } + out.push(Self { source: self.source.clone(), span: start..end }); + cursor = end; + } + if cursor != field.span.end { + return Err("sidecar list has trailing bytes".into()); + } + Ok(out) + } + + /// Replace only named fields; untouched source field bytes and key encodings stay exact. + fn patch(&self, changes: &[(&str, Option>)]) -> Result> { + let (header, fields) = self.fields()?; + let mut seen = vec![false; changes.len()]; + let mut items = Vec::new(); + let mut count = 0usize; + for field in fields { + let found = changes.iter().position(|(key, _)| field.key.is(key)); + if let Some(index) = found { + if seen.get(index).copied() == Some(true) { + return Err("cannot edit a duplicate sidecar field".into()); + } + if let Some(seen) = seen.get_mut(index) { + *seen = true; + } + if let Some(Some(value)) = changes.get(index).map(|(_, value)| value.as_ref()) { + let end = field.span.start.checked_add(field.key_len).ok_or("sidecar offset overflow")?; + extend(&mut items, self.source.get(field.span.start..end).ok_or("invalid sidecar key span")?)?; + extend(&mut items, value)?; + count = count.checked_add(1).ok_or("sidecar count overflow")?; + } + } else { + extend(&mut items, self.source.get(field.span).ok_or("invalid sidecar field span")?)?; + count = count.checked_add(1).ok_or("sidecar count overflow")?; + } + } + for (index, (key, value)) in changes.iter().enumerate() { + if !seen.get(index).copied().unwrap_or(false) + && let Some(value) = value + { + let key = Id::new(key); + match key { + Id::Code(bytes) => { + extend(&mut items, &0u32.to_be_bytes())?; + extend(&mut items, &bytes)?; + } + Id::Str(bytes) => { + let len = u32::try_from(bytes.len()).map_err(|_| "sidecar key is too long")?; + extend(&mut items, &len.to_be_bytes())?; + extend(&mut items, &bytes)?; + } + } + extend(&mut items, value)?; + count = count.checked_add(1).ok_or("sidecar count overflow")?; + } + } + let prefix = header.checked_sub(4).ok_or("invalid sidecar header")?; + let mut out = self.bytes().get(..prefix).ok_or("invalid sidecar header")?.to_vec(); + extend(&mut out, &u32::try_from(count).map_err(|_| "too many sidecar fields")?.to_be_bytes())?; + extend(&mut out, &items)?; + Ok(out) + } +} + +fn objects(entries: &[Vec]) -> Result> { + if entries.len() > atn::MAX_TOTAL_ACTIONS { + return Err("too many sidecar objects".into()); + } + let mut out = b"VlLs".to_vec(); + out.extend_from_slice(&u32::try_from(entries.len()).map_err(|_| "too many sidecar objects")?.to_be_bytes()); + for entry in entries { + let len = out.len().checked_add(4).and_then(|len| len.checked_add(entry.len())).ok_or("sidecar size overflow")?; + if len > MAX_BYTES { + return Err("sidecar exceeds the action-file byte limit".into()); + } + out.extend_from_slice(b"Objc"); + out.extend(entry); + } + Ok(out) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Source { + wrapper: Vec, + outer: RawDescriptor, + root: RawDescriptor, + signature: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SetMetadata { + raw: RawDescriptor, + source: Arc, + ordinal: usize, +} + +/// Runtime preservation data. The public protocol still edits only description/icon strings. +/// Bytes, not floating-point descriptor equality, define clone/dirty/stale comparisons. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionMetadata { + raw: RawDescriptor, + set: Option>, + source: Arc, + description: String, + icon: String, + thumbnail: i32, + reset_icon: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SetRowMetadata { + pub(crate) slot: usize, + pub(crate) row: String, + pub(crate) source: Arc, +} + +/// Root's editor.update hook calls this after validating an explicitly supplied icon string. +pub(crate) fn explicit_icon(action: &mut Action) { + if let Some(metadata) = action.atn_metadata.as_mut() { + if action.icon.is_empty() && !matches!(metadata.thumbnail, 0 | 3) { + metadata.reset_icon = true; + } + } +} + +pub(super) fn empty_source(state: &ActionState, slot: usize) -> Option> { + state.atn_set_rows.iter().find(|entry| entry.slot == slot && state.sets.get(slot) == Some(&entry.row)).map(|entry| entry.source.clone()) +} + +pub(super) fn install_row(state: &mut ActionState, name: &str, source: Option>) { + let slot = state.sets.len(); + state.sets.push(name.to_string()); + if let Some(source) = source { + state.atn_set_rows.push(SetRowMetadata { slot, row: name.to_string(), source }); + } +} + +/// Copy every physical row of one logical folder, retaining empty-set metadata and moving +/// surviving row carriers with the insertion. Action clones already retain their raw entries. +pub(crate) fn duplicate_set_rows(state: &mut ActionState, source: &str, name: &str) { + let rows: Vec<_> = state.sets.iter().enumerate().filter_map(|(slot, row)| (row == source).then_some(slot)).collect(); + let at = rows.first().map_or(state.sets.len(), |slot| slot + 1); + let mut copied: Vec<_> = rows.iter().map(|&slot| empty_source(state, slot)).collect(); + if copied.is_empty() { + copied.push(group_source(state, source)); + } + let count = copied.len(); + for entry in &mut state.atn_set_rows { + if entry.slot >= at { + entry.slot = entry.slot.saturating_add(count); + } + } + state.sets.splice(at..at, std::iter::repeat_n(name.to_string(), count)); + for (offset, source) in copied.into_iter().enumerate() { + if let Some(source) = source { + state.atn_set_rows.push(SetRowMetadata { slot: at + offset, row: name.to_string(), source }); + } + } +} + +/// Retire selected CURRENT rows and move surviving carriers with their rows. +pub(crate) fn retire_sets(state: &mut ActionState, names: &std::collections::HashSet) { + let metadata: std::collections::BTreeMap = + std::mem::take(&mut state.atn_set_rows).into_iter().map(|entry| (entry.slot, entry)).collect(); + let rows = std::mem::take(&mut state.sets); + for (old, row) in rows.into_iter().enumerate() { + if names.contains(&row) { + continue; + } + let slot = state.sets.len(); + if let Some(entry) = metadata.get(&old).filter(|entry| entry.row == row) { + state.atn_set_rows.push(SetRowMetadata { slot, row: row.clone(), source: entry.source.clone() }); + } + state.sets.push(row); + } +} + +fn group_source(state: &ActionState, name: &str) -> Option> { + let actions: Vec<&Action> = state.list.iter().filter(|action| super::logical_set_name(action.set.as_deref()) == name).collect(); + let mut source = match source_for(&actions) { + Ok(source) => source, + Err(_) => return None, + }; + for (slot, _) in state.sets.iter().enumerate().filter(|(_, row)| row.as_str() == name) { + if let Some(candidate) = empty_source(state, slot) { + if let Some(current) = &source { + if !same_set(current, &candidate) { + return None; + } + } else { + source = Some(candidate); + } + } + } + source +} + +fn same_set(left: &Arc, right: &Arc) -> bool { + Arc::ptr_eq(&left.source, &right.source) && left.ordinal == right.ordinal +} + +/// Small assignSet hook: preserve the action entry but retire its old set binding on a real move. +pub(crate) fn assign_set_metadata(state: &mut ActionState, action: usize, target: Option<&str>) { + let Some(current) = state.list.get(action) else { return }; + if super::logical_set_name(current.set.as_deref()) == super::logical_set_name(target) { + return; + } + let source = group_source(state, super::logical_set_name(target)); + if let Some(metadata) = state.list.get_mut(action).and_then(|action| action.atn_metadata.as_mut()) { + metadata.set = source; + } +} + +pub(super) fn selection<'a>(state: &ActionState, action: &'a Action) -> Selection<'a> { + let source = + action.atn_metadata.as_ref().and_then(|metadata| metadata.set.clone()).or_else(|| group_source(state, super::logical_set_name(action.set.as_deref()))); + Selection { actions: vec![action], source } +} + +pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result)>> { + if state.list.len() > atn::MAX_TOTAL_ACTIONS || state.sets.len() > atn::MAX_SETS { + return Err("action library exceeds archive limits".into()); + } + let mut names = std::collections::HashSet::new(); + let mut out: Vec<(&str, Selection<'a>)> = Vec::new(); + for name in state.list.iter().map(|action| super::logical_set_name(action.set.as_deref())).chain(state.sets.iter().map(String::as_str)) { + if only.is_some_and(|only| only != name) || !names.insert(name) { + continue; + } + let fallback = group_source(state, name); + let before = out.len(); + for action in state.list.iter().filter(|action| super::logical_set_name(action.set.as_deref()) == name) { + let source = action.atn_metadata.as_ref().and_then(|metadata| metadata.set.clone()).or_else(|| fallback.clone()); + let existing = out.last_mut().filter(|(previous, selection)| { + *previous == name + && match (&selection.source, &source) { + (None, None) => true, + (Some(left), Some(right)) => same_set(left, right), + _ => false, + } + }); + if let Some((_, selection)) = existing { + selection.actions.push(action); + } else { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); + } + out.push((name, Selection { actions: vec![action], source })); + } + } + for (slot, _) in state.sets.iter().enumerate().filter(|(_, row)| row.as_str() == name) { + if let Some(source) = empty_source(state, slot) { + if !out.iter().skip(before).any(|(_, selection)| selection.source.as_ref().is_some_and(|existing| same_set(existing, &source))) { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); + } + out.push((name, Selection { actions: Vec::new(), source: Some(source) })); + } + } + } + if out.len() == before { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); + } + out.push((name, Selection { actions: Vec::new(), source: fallback })); + } + } + // Preserve physical rows only when doing so preserves the panel's group/action order. + // An explicit move can change that projection, including S1/S2/S1 runs within one group. + let mut slots = std::collections::HashMap::new(); + for row in &state.atn_set_rows { + if state.sets.get(row.slot) == Some(&row.row) { + let key = (row.row.as_str(), Arc::as_ptr(&row.source.source), row.source.ordinal); + slots.entry(key).and_modify(|slot: &mut usize| *slot = (*slot).min(row.slot)).or_insert(row.slot); + } + } + let mut physical: Vec<_> = (0..out.len()).collect(); + physical.sort_by_cached_key(|&index| { + let (name, selection) = &out[index]; + selection.source.as_ref().and_then(|source| slots.get(&(*name, Arc::as_ptr(&source.source), source.ordinal)).copied()).unwrap_or(usize::MAX) + }); + if panel_projection(&out, 0..out.len()) != panel_projection(&out, physical.iter().copied()) { + return Ok(out); + } + Ok(physical + .into_iter() + .map(|index| { + let (name, selection) = &out[index]; + (*name, Selection { actions: selection.actions.clone(), source: selection.source.clone() }) + }) + .collect()) +} + +/// Temporary pointer identities compare panel order without comparing action/descriptor values. +fn panel_projection<'a>(groups: &[(&'a str, Selection<'a>)], order: impl IntoIterator) -> Vec<(&'a str, Vec<*const Action>)> { + let mut actions = std::collections::HashMap::<&str, Vec<*const Action>>::new(); + let mut all = Vec::new(); + let mut nonempty = Vec::new(); + for index in order { + let (name, selection) = &groups[index]; + let members = actions.entry(*name).or_insert_with(|| { + all.push(*name); + Vec::new() + }); + if members.is_empty() && !selection.actions.is_empty() { + nonempty.push(*name); + } + members.extend(selection.actions.iter().map(|action| std::ptr::from_ref(*action))); + } + all.retain(|name| actions.get(name).is_some_and(Vec::is_empty)); + nonempty.extend(all); + nonempty.into_iter().map(|name| (name, actions.remove(name).unwrap_or_default())).collect() +} + +pub(super) struct Decoded { + pub(super) hidden: Option, + pub(super) sets: Vec>>, + pub(super) actions: Vec>>, +} + +fn unique<'a>(descriptor: &'a Descriptor, key: &str) -> Result<&'a D> { + let mut fields = descriptor.items.iter().filter(|(id, _)| id.is(key)); + let value = fields.next().map(|(_, value)| value).ok_or_else(|| format!("missing sidecar {key}"))?; + if fields.next().is_some() { + return Err(format!("duplicate sidecar {key}")); + } + Ok(value) +} + +pub(super) fn has_payload(item: &AtnItem) -> bool { + (item.event.is("stop") || item.event.is("Stop")) && item.descriptor.as_ref().is_some_and(|descriptor| descriptor.get(MODERN).is_some()) +} + +fn payload(file: &AtnFile) -> Option<&[u8]> { + if file.name.to_string_lossy() != "Action sidecar" || file.expanded { + return None; + } + let [action] = file.actions.as_slice() else { return None }; + if action.name.to_string_lossy() != "Play for details" || action.expanded || action.function_key != 0 || action.shift || action.command || action.color != 0 + { + return None; + } + let [item] = action.items.as_slice() else { return None }; + if item.expanded || !item.enabled || !item.with_dialog || item.dialog_options != 2 || item.dictionary != b"Stop" || !has_payload(item) { + return None; + } + let descriptor = item.descriptor.as_ref()?; + if !descriptor.class_id.is("null") || !descriptor.name.0.is_empty() { + return None; + } + if !matches!(descriptor.get("Msge"), Some(D::Text(_))) || descriptor.get("Cntn") != Some(&D::Boolean(true)) { + return None; + } + let D::RawData(bytes) = descriptor.get(MODERN)? else { return None }; + if bytes.get(..4) != Some(&16u32.to_be_bytes()) { + return None; + } + bytes.get(4..) +} + +pub(super) fn decode(files: &[AtnFile]) -> Result { + let mut result = Decoded { hidden: None, sets: vec![None; files.len()], actions: files.iter().map(|file| vec![None; file.actions.len()]).collect() }; + for (hidden, file) in files.iter().enumerate() { + let Some(bytes) = payload(file) else { continue }; + if hidden.checked_add(1) != Some(files.len()) { + return Err("Actions sidecar must be the final physical set".into()); + } + if result.hidden.is_some() { + return Err("ambiguous multiple Actions sidecars".into()); + } + let root = RawDescriptor::new(bytes)?; + let parsed = root.parsed()?; + if !parsed.class_id.is("null") || !parsed.name.0.is_empty() { + return Err("unsupported Actions sidecar root".into()); + } + let sets = root.list("sets")?; + if sets.is_empty() { + return Err("Actions sidecar requires at least one real set".into()); + } + if sets.len().checked_add(1) != Some(files.len()) { + return Err("sidecar set count does not match the action file".into()); + } + let descriptor = + file.actions.first().and_then(|action| action.items.first()).and_then(|item| item.descriptor.as_ref()).ok_or("missing sidecar descriptor")?; + unique(descriptor, MODERN)?; + unique(descriptor, "Msge")?; + unique(descriptor, "Cntn")?; + let outer = RawDescriptor::new(&descriptor.to_bytes())?; + let outer_signature = outer.patch(&[(MODERN, Some(value_bytes(D::RawData(Vec::new()))?))])?; + let mut signature_file = file.clone(); + let signature_item = signature_file.actions.first_mut().and_then(|action| action.items.first_mut()).ok_or("missing sidecar signature item")?; + signature_item.descriptor = Some(Descriptor::from_bytes(&outer_signature).map_err(|error| error.to_string())?); + let mut signature = atn::write(&signature_file).map_err(|error| error.to_string())?; + signature.extend(root.patch(&[("sets", Some(objects(&[])?))])?); + let source = Arc::new(Source { wrapper: atn::write(file).map_err(|error| error.to_string())?, outer, root, signature }); + let mut ordinal = 0usize; + for (index, real) in files.iter().enumerate().filter(|(index, _)| *index != hidden) { + let raw = sets.get(ordinal).ok_or("missing positional sidecar set")?.clone(); + let parsed = raw.parsed()?; + if !parsed.class_id.is("null") || !parsed.name.0.is_empty() || !matches!(unique(&parsed, "setColorIndex")?, D::Integer(_)) { + return Err("invalid positional sidecar set".into()); + } + let entries = raw.list("actions")?; + if entries.len() != real.actions.len() { + return Err("sidecar action count does not match its set".into()); + } + let set = Arc::new(SetMetadata { raw, source: source.clone(), ordinal }); + if let Some(slot) = result.sets.get_mut(index) { + *slot = Some(set.clone()); + } + for (at, raw) in entries.into_iter().enumerate() { + let parsed = raw.parsed()?; + if !parsed.class_id.is("null") || !parsed.name.0.is_empty() { + return Err("invalid sidecar action object".into()); + } + let D::Text(description) = unique(&parsed, "actionDescriptionKey")? else { + return Err("invalid action description".into()); + }; + let D::Integer(thumbnail) = unique(&parsed, "actionThumbnailTypeKey")? else { + return Err("invalid action thumbnail type".into()); + }; + if !matches!(unique(&parsed, "actionPreviewKey")?, D::Boolean(_)) + || !matches!(unique(&parsed, "actionGenerativeKey")?, D::Boolean(_)) + || !matches!(unique(&parsed, "actionPreconditionVal")?, D::Integer(_)) + { + return Err("invalid measured action metadata fields".into()); + } + let icon = if *thumbnail == 3 { + let D::Text(icon) = unique(&parsed, "actionIconKey")? else { + return Err("invalid action icon".into()); + }; + icon.to_string_lossy() + } else { + String::new() + }; + // Unknown values of the other measured properties remain opaque and unchanged. + let metadata = ActionMetadata { + raw, + set: Some(set.clone()), + source: source.clone(), + description: description.to_string_lossy(), + icon, + thumbnail: *thumbnail, + reset_icon: false, + }; + if let Some(slot) = result.actions.get_mut(index).and_then(|actions| actions.get_mut(at)) { + *slot = Some(metadata); + } + } + ordinal = ordinal.checked_add(1).ok_or("sidecar position overflow")?; + } + result.hidden = Some(hidden); + } + Ok(result) +} + +pub(super) fn apply(action: &mut Action, metadata: ActionMetadata) { + action.description = metadata.description.clone(); + action.icon = metadata.icon.clone(); + action.atn_metadata = Some(metadata); +} + +fn entry(action: &Action) -> Result> { + // Bound strings before UnicodeString allocates their UTF16 representation. + let units = action.description.encode_utf16().count().checked_add(action.icon.encode_utf16().count()).ok_or("metadata string length overflow")?; + if units.checked_mul(2).is_none_or(|bytes| bytes > MAX_BYTES.saturating_sub(1024)) { + return Err("action metadata strings exceed the action-file byte limit".into()); + } + if let Some(metadata) = &action.atn_metadata { + let mut changes = Vec::new(); + if action.description != metadata.description { + changes.push(("actionDescriptionKey", Some(value_bytes(D::Text(UnicodeString::new(&action.description)))?))); + } + if action.icon != metadata.icon || metadata.reset_icon { + changes.push(("actionThumbnailTypeKey", Some(value_bytes(D::Integer(if action.icon.is_empty() { 0 } else { 3 }))?))); + changes.push(("actionIconKey", if action.icon.is_empty() { None } else { Some(value_bytes(D::Text(UnicodeString::new(&action.icon)))?) })); + } + return if changes.is_empty() { Ok(metadata.raw.bytes().to_vec()) } else { metadata.raw.patch(&changes) }; + } + let mut descriptor = Descriptor::new("null") + .with("actionDescriptionKey", D::Text(UnicodeString::new(&action.description))) + .with("actionThumbnailTypeKey", D::Integer(if action.icon.is_empty() { 0 } else { 3 })); + if !action.icon.is_empty() { + descriptor = descriptor.with("actionIconKey", D::Text(UnicodeString::new(&action.icon))); + } + Ok(descriptor + .with("actionPreviewKey", D::Boolean(false)) + .with("actionGenerativeKey", D::Boolean(false)) + .with("actionPreconditionVal", D::Integer(-1)) + .to_bytes()) +} + +pub(super) struct Selection<'a> { + pub(super) actions: Vec<&'a Action>, + pub(super) source: Option>, +} + +pub(super) fn source_for(actions: &[&Action]) -> Result>> { + let mut source: Option> = None; + for candidate in actions.iter().filter_map(|action| action.atn_metadata.as_ref()).filter_map(|metadata| metadata.set.as_ref()) { + if let Some(source) = &source { + if !Arc::ptr_eq(&source.source, &candidate.source) || source.ordinal != candidate.ordinal { + return Err("selected actions have conflicting source-set metadata; export their sets separately".into()); + } + } else { + source = Some(candidate.clone()); + } + } + Ok(source) +} + +pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Result<()> { + if files.len() != selected.len() { + return Err("sidecar export selection mismatch".into()); + } + let needed = selected.iter().any(|selection| { + selection.source.is_some() + || selection.actions.iter().any(|action| action.atn_metadata.is_some() || !action.description.is_empty() || !action.icon.is_empty()) + }); + if !needed { + return Ok(()); + } + let actions = files.iter().try_fold(1usize, |count, file| count.checked_add(file.actions.len())).ok_or("sidecar action count overflow")?; + let steps = files + .iter() + .flat_map(|file| &file.actions) + .try_fold(1usize, |count, action| count.checked_add(action.items.len())) + .ok_or("sidecar step count overflow")?; + if files.len() >= atn::MAX_SETS || actions > atn::MAX_TOTAL_ACTIONS || steps > atn::MAX_TOTAL_ITEMS { + return Err("actions archive leaves no space for its metadata sidecar".into()); + } + let mut source: Option> = None; + let mut sets = Vec::with_capacity(selected.len()); + let mut set_bytes = 8usize; + for selection in selected { + let set_source = selection.source.clone().or(source_for(&selection.actions)?); + let sources = set_source + .iter() + .map(|set| &set.source) + .chain(selection.actions.iter().filter_map(|action| action.atn_metadata.as_ref()).map(|metadata| &metadata.source)); + for candidate in sources { + if let Some(source) = &source { + if source.signature != candidate.signature { + return Err("source sidecar notes or archive fields conflict; export separately".into()); + } + } else { + source = Some(candidate.clone()); + } + } + let mut entries = Vec::with_capacity(selection.actions.len().min(256)); + let mut entry_bytes = 8usize; + for action in &selection.actions { + let entry = entry(action)?; + entry_bytes = entry_bytes + .checked_add(4) + .and_then(|count| count.checked_add(entry.len())) + .filter(|count| *count <= MAX_BYTES) + .ok_or("sidecar action list exceeds the action-file byte limit")?; + entries.push(entry); + } + let actions = objects(&entries)?; + let set = match set_source { + Some(set) => set.raw.patch(&[("actions", Some(actions))])?, + None => { + let raw = RawDescriptor::new(&Descriptor::new("null").with("setColorIndex", D::Integer(0)).with("actions", D::List(Vec::new())).to_bytes())?; + raw.patch(&[("actions", Some(actions))])? + } + }; + set_bytes = set_bytes + .checked_add(4) + .and_then(|count| count.checked_add(set.len())) + .filter(|count| *count <= MAX_BYTES) + .ok_or("sidecar set list exceeds the action-file byte limit")?; + sets.push(set); + } + let root = match &source { + Some(source) => source.root.patch(&[("sets", Some(objects(&sets)?))])?, + None => RawDescriptor::new(&Descriptor::new("null").with("sets", D::List(Vec::new())).to_bytes())?.patch(&[("sets", Some(objects(&sets)?))])?, + }; + let mut payload = 16u32.to_be_bytes().to_vec(); + extend(&mut payload, &root)?; + let outer = match &source { + Some(source) => source.outer.patch(&[(MODERN, Some(value_bytes(D::RawData(payload))?))])?, + None => Descriptor::new("null") + .with("Msge", D::Text(UnicodeString::new("This set stores additional action details. Older editors may ignore it."))) + .with("Cntn", D::Boolean(true)) + .with(MODERN, D::RawData(payload)) + .to_bytes(), + }; + let descriptor = Descriptor::from_bytes(&outer).map_err(|error| error.to_string())?; + let mut wrapper = if let Some(source) = source { + atn::parse(&source.wrapper).map_err(|error| error.to_string())? + } else { + AtnFile { + name: UnicodeString::new_nul("Action sidecar"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Play for details"), + items: vec![AtnItem { + event: AtnEvent::name("stop"), + dictionary: b"Stop".to_vec(), + enabled: true, + with_dialog: true, + dialog_options: 2, + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + } + }; + let item = wrapper.actions.first_mut().and_then(|action| action.items.first_mut()).ok_or("missing output sidecar wrapper")?; + item.descriptor = Some(descriptor); + files.push(wrapper); + Ok(()) +} + +#[cfg(test)] +#[path = "sidecar_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs b/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs new file mode 100644 index 00000000000..bb749772da3 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs @@ -0,0 +1,612 @@ +//! These fixtures spell the measured wire layout directly; no production writer builds the oracle. +use super::*; +use crate::Session; +use serde_json::json; + +fn be(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_be_bytes()); +} +fn text(value: &str) -> Vec { + let units: Vec<_> = value.encode_utf16().collect(); + let mut out = Vec::new(); + be(&mut out, units.len() as u32); + for unit in units { + out.extend_from_slice(&unit.to_be_bytes()); + } + out +} +fn id(out: &mut Vec, value: &str) { + be(out, if value.len() == 4 { 0 } else { value.len() as u32 }); + out.extend_from_slice(value.as_bytes()); +} +fn object(fields: &[(&str, &[u8; 4], Vec)]) -> Vec { + let mut out = text(""); + id(&mut out, "null"); + be(&mut out, fields.len() as u32); + for (key, kind, bytes) in fields { + id(&mut out, key); + out.extend_from_slice(*kind); + out.extend(bytes); + } + out +} +fn list(objects: &[Vec]) -> Vec { + let mut out = Vec::new(); + be(&mut out, objects.len() as u32); + for object in objects { + out.extend_from_slice(b"Objc"); + out.extend(object); + } + out +} +fn raw(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + be(&mut out, bytes.len() as u32); + out.extend_from_slice(bytes); + out +} +fn action_entry(index: usize) -> Vec { + let mut fields = vec![ + ("actionDescriptionKey", b"TEXT", text(if index == 0 { "First description\0" } else { "Second description\0" })), + ("actionThumbnailTypeKey", b"long", (if index == 0 { 3i32 } else { 42i32 }).to_be_bytes().to_vec()), + ("actionPreviewKey", b"bool", vec![2]), // Legal noncanonical bool must remain byte-exact. + ("actionGenerativeKey", b"bool", vec![0]), + ("actionPreconditionVal", b"long", 73i32.to_be_bytes().to_vec()), + ("futureDouble", b"doub", 0x7ff8_0000_0000_0042u64.to_be_bytes().to_vec()), + ("futureActionBytes", b"tdta", raw(&[0, 255, index as u8])), + ]; + if index == 0 { + fields.push(("actionIconKey", b"TEXT", text("future-icon\0"))); + } + object(&fields) +} +fn set_body(out: &mut Vec, name: &str, key: Option) { + out.extend(text(&format!("{name}\0"))); + out.push(0); + be(out, u32::from(key.is_some())); + if let Some(key) = key { + action_body(out, key); + } +} +fn action_body(out: &mut Vec, key: u16) { + out.extend_from_slice(&key.to_be_bytes()); + out.extend_from_slice(&[0, 0, 0, 0]); + out.extend(text("Same\0")); + out.push(0); + be(out, 0); +} +fn set_entry(index: usize, entries: &[Vec]) -> Vec { + object(&[ + ("setColorIndex", b"long", (11 + index as i32 * 11).to_be_bytes().to_vec()), + ("actions", b"VlLs", list(entries)), + ("futureSet", b"tdta", raw(&[9, index as u8])), + ]) +} +fn sidecar_data(sets: &[Vec], note: &str) -> (Vec, Vec) { + let root = object(&[("sets", b"VlLs", list(sets)), ("futureGlobal", b"bool", vec![2])]); + let mut payload = 16u32.to_be_bytes().to_vec(); + payload.extend(root); + let outer = object(&[("Msge", b"TEXT", text(note)), ("Cntn", b"bool", vec![1]), (MODERN, b"tdta", raw(&payload))]); + (payload, outer) +} +struct Fixture { + bytes: Vec, + payload: Vec, + outer: Vec, + entries: Vec>, +} +fn fixture(names: &[&str], empty: bool, note: &str) -> Fixture { + fixture_at(names, empty.then_some(names.len()), note) +} +fn fixture_at(names: &[&str], empty_at: Option, note: &str) -> Fixture { + fixture_rows(&names.iter().map(|name| (*name, 1)).collect::>(), empty_at, note) +} +fn fixture_rows(rows: &[(&str, usize)], empty_at: Option, note: &str) -> Fixture { + let entries: Vec<_> = (0..rows.iter().map(|(_, count)| *count).sum::()).map(action_entry).collect(); + let mut at = 0; + let mut sets: Vec<_> = rows + .iter() + .enumerate() + .map(|(index, (_, count))| { + let set = set_entry(index, &entries[at..at + *count]); + at += *count; + set + }) + .collect(); + if let Some(slot) = empty_at { + sets.insert(slot, object(&[("setColorIndex", b"long", 99i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[]))])); + } + let (payload, outer) = sidecar_data(&sets, note); + let mut bytes = 16u32.to_be_bytes().to_vec(); + at = 0; + for index in 0..=rows.len() { + if empty_at == Some(index) { + set_body(&mut bytes, "Empty", None); + } + if let Some((name, count)) = rows.get(index) { + bytes.extend(text(&format!("{name}\0"))); + bytes.push(0); + be(&mut bytes, *count as u32); + for action in at..at + *count { + action_body(&mut bytes, 6 + action as u16); + } + at += *count; + } + } + bytes.extend(text("Action sidecar\0")); + bytes.push(0); + be(&mut bytes, 1); + bytes.extend_from_slice(&[0; 6]); + bytes.extend(text("Play for details\0")); + bytes.push(0); + be(&mut bytes, 1); + bytes.extend_from_slice(&[0, 1, 1, 2]); + bytes.extend_from_slice(b"TEXT"); + be(&mut bytes, 4); + bytes.extend_from_slice(b"stop"); + be(&mut bytes, 4); + bytes.extend_from_slice(b"Stop"); + bytes.extend_from_slice(&(-1i32).to_be_bytes()); + bytes.extend(&outer); + Fixture { bytes, payload, outer, entries } +} +fn loaded(bytes: &[u8]) -> Session { + let mut session = Session::new(); + session.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(bytes)})).unwrap(); + session +} +fn saved_bytes(session: &mut Session, params: serde_json::Value) -> Vec { + let response = session.execute("actions.save", params).unwrap(); + photocraft_paint::tile::b64_decode(response["data"].as_str().unwrap()).unwrap() +} +fn saved(session: &mut Session, params: serde_json::Value) -> Vec { + atn::parse_sets(&saved_bytes(session, params)).unwrap() +} +fn panel_groups(session: &Session) -> Vec<(String, Vec)> { + crate::actions_edit_cmds::set_names(session) + .into_iter() + .map(|name| { + let actions = session + .actions + .list + .iter() + .filter(|action| super::super::logical_set_name(action.set.as_deref()) == name) + .map(|action| action.name.clone()) + .collect(); + (name, actions) + }) + .collect() +} +fn metadata(files: &[AtnFile]) -> RawDescriptor { + RawDescriptor::new(payload(files.last().unwrap()).unwrap()).unwrap() +} +fn set_metadata(files: &[AtnFile], name: &str) -> RawDescriptor { + let index = files.iter().position(|file| file.name.to_string_lossy() == name).unwrap(); + metadata(files).list("sets").unwrap()[index].clone() +} +fn integer(raw: &RawDescriptor, key: &str) -> i32 { + match raw.parsed().unwrap().get(key).unwrap() { + D::Integer(value) => *value, + _ => panic!("integer expected"), + } +} + +#[test] +fn public_load_save_all_keeps_positional_metadata_and_raw_descriptor_bytes() { + let fixture = fixture(&["A", "B"], true, "Original note\0"); + let mut session = loaded(&fixture.bytes); + assert_eq!(session.actions.list.len(), 2); + assert_eq!(session.actions.list[0].description, "First description"); + assert_eq!(session.actions.list[0].icon, "future-icon"); + assert_eq!(session.actions.list[1].description, "Second description"); + assert_eq!(session.actions.list[1].icon, ""); + assert_eq!(session.actions.sets, ["A", "B", "Empty"]); + let files = saved(&mut session, json!({})); + assert_eq!(files.len(), 4); + assert_eq!(files.last().unwrap().actions[0].items[0].descriptor.as_ref().unwrap().to_bytes(), fixture.outer); + assert_eq!(payload(files.last().unwrap()).unwrap(), fixture.payload.get(4..).unwrap()); + assert_eq!(integer(&set_metadata(&files, "Empty"), "setColorIndex"), 99); +} + +#[test] +fn unedited_empty_first_and_interleaved_rows_keep_whole_sidecar_bytes_and_positions() { + for (fixture, expected_names, expected_actions, expected_colors) in [ + (fixture_at(&["A"], Some(0), "Empty first\0"), vec!["Empty", "A"], vec![vec![], vec!["Same"]], vec![99, 11]), + (fixture(&["A", "B", "A"], false, "Interleaved\0"), vec!["A", "B", "A"], vec![vec!["Same"], vec!["Same 2"], vec!["Same 3"]], vec![11, 22, 33]), + ] { + let mut session = loaded(&fixture.bytes); + let files = saved(&mut session, json!({})); + assert_eq!(files.len(), expected_names.len() + 1); + let real = &files[..expected_names.len()]; + assert_eq!(real.iter().map(|file| file.name.to_string_lossy()).collect::>(), expected_names); + for (file, expected) in real.iter().zip(expected_actions) { + assert_eq!(file.actions.iter().map(|action| action.name.to_string_lossy()).collect::>(), expected); + } + let outer = files.last().unwrap().actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(outer.to_bytes(), fixture.outer, "the whole unedited wire descriptor is the independent oracle"); + let Some(D::RawData(bytes)) = outer.get(MODERN) else { panic!("missing sidecar data") }; + assert_eq!(bytes, &fixture.payload); + let descriptor = Descriptor::from_bytes(&bytes[4..]).unwrap(); + let Some(D::List(sets)) = descriptor.get("sets") else { panic!("missing physical set metadata") }; + assert_eq!(sets.len(), real.len()); + for (value, expected) in sets.iter().zip(expected_colors) { + let D::Descriptor(set) = value else { panic!("expected set object") }; + assert_eq!(set.get("setColorIndex"), Some(&D::Integer(expected))); + } + } +} + +#[test] +fn moving_a_later_physical_set_keeps_panel_order_and_positional_metadata_after_reload() { + let note = "Interleaved move\0"; + let fixture = fixture(&["A", "B", "A"], false, note); + let mut session = loaded(&fixture.bytes); + session.execute("actions.move", json!({"action":"Same 3","to":0})).unwrap(); + let expected: Vec<(String, Vec)> = vec![("A".into(), vec!["Same 3".into(), "Same".into()]), ("B".into(), vec!["Same 2".into()])]; + assert_eq!(panel_groups(&session), expected); + let bytes = saved_bytes(&mut session, json!({})); + let files = atn::parse_sets(&bytes).unwrap(); + assert_eq!(files.len(), 4); + let sets = metadata(&files).list("sets").unwrap(); + for (index, name, action, source) in [(0, "A", "Same 3", 2), (1, "A", "Same", 0), (2, "B", "Same 2", 1)] { + assert_eq!(files[index].name.to_string_lossy(), name); + assert_eq!(files[index].actions.len(), 1); + assert_eq!(files[index].actions[0].name.to_string_lossy(), action); + assert_eq!(sets[index].bytes(), set_entry(source, std::slice::from_ref(&fixture.entries[source]))); + } + let (_, outer) = sidecar_data( + &[ + set_entry(2, std::slice::from_ref(&fixture.entries[2])), + set_entry(0, std::slice::from_ref(&fixture.entries[0])), + set_entry(1, std::slice::from_ref(&fixture.entries[1])), + ], + note, + ); + assert_eq!(files[3].actions[0].items[0].descriptor.as_ref().unwrap().to_bytes(), outer); + assert_eq!(panel_groups(&loaded(&bytes)), expected); +} + +#[test] +fn moving_between_actions_of_one_source_keeps_three_source_runs_after_reload() { + let note = "Source runs\0"; + let fixture = fixture_rows(&[("A", 2), ("A", 1)], None, note); + let mut session = loaded(&fixture.bytes); + session.execute("actions.move", json!({"action":"Same 3","to":1})).unwrap(); + let expected: Vec<(String, Vec)> = vec![("A".into(), vec!["Same".into(), "Same 3".into(), "Same 2".into()])]; + assert_eq!(panel_groups(&session), expected); + let bytes = saved_bytes(&mut session, json!({})); + let files = atn::parse_sets(&bytes).unwrap(); + assert_eq!(files.len(), 4); + let sets = metadata(&files).list("sets").unwrap(); + let mut expected_sets = Vec::new(); + for (index, action, source, entry) in [(0, "Same", 0, 0), (1, "Same 3", 1, 2), (2, "Same 2", 0, 1)] { + assert_eq!(files[index].name.to_string_lossy(), "A"); + assert_eq!(files[index].actions.len(), 1); + assert_eq!(files[index].actions[0].name.to_string_lossy(), action); + let raw = set_entry(source, std::slice::from_ref(&fixture.entries[entry])); + assert_eq!(sets[index].bytes(), raw); + expected_sets.push(raw); + } + let (_, outer) = sidecar_data(&expected_sets, note); + assert_eq!(files[3].actions[0].items[0].descriptor.as_ref().unwrap().to_bytes(), outer); + assert_eq!(panel_groups(&loaded(&bytes)), expected); +} + +#[test] +fn duplicate_names_are_renamed_after_metadata_and_shortcuts_bind_by_position() { + let fixture = fixture(&["A", "B"], false, "Note\0"); + let mut session = Session::new(); + session.actions.list.push(Action::new("Same", vec![])); + let response = session.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&fixture.bytes), "replace": false})).unwrap(); + assert_eq!(response["actions"], json!(["Same 2", "Same 3"])); + assert_eq!(session.actions.list[1].description, "First description"); + assert_eq!(session.actions.list[2].description, "Second description"); + assert_eq!(session.prefs().shortcuts.get("actions.play:Same 2").map(String::as_str), Some("F6")); + assert_eq!(session.prefs().shortcuts.get("actions.play:Same 3").map(String::as_str), Some("F7")); + let files = saved(&mut session, json!({"action": "Same 3"})); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name.to_string_lossy(), "B"); + let sets = metadata(&files).list("sets").unwrap(); + assert_eq!(integer(&sets[0], "setColorIndex"), 22); + assert_eq!(sets[0].list("actions").unwrap()[0].bytes(), fixture.entries[1]); +} + +#[test] +fn selected_set_and_empty_set_export_rebuild_only_their_entries() { + let fixture = fixture(&["A", "B"], true, "Note\0"); + let mut session = loaded(&fixture.bytes); + let files = saved(&mut session, json!({"set": "B"})); + assert_eq!(metadata(&files).list("sets").unwrap().len(), 1); + assert_eq!(set_metadata(&files, "B").list("actions").unwrap()[0].bytes(), fixture.entries[1]); + let empty = saved(&mut session, json!({"set": "Empty"})); + assert!(empty[0].actions.is_empty()); + assert_eq!(integer(&set_metadata(&empty, "Empty"), "setColorIndex"), 99); +} + +#[test] +fn edits_preserve_unknown_thumbnail_until_explicit_choice_and_all_other_raw_fields() { + let fixture = fixture(&["A", "B"], false, "Note\0"); + let mut session = loaded(&fixture.bytes); + let action = &mut session.actions.list[1]; + assert_eq!(*action, action.clone(), "raw NaN bytes do not break draft equality"); + action.description = "Edited".into(); + let edited = RawDescriptor::new(&entry(action).unwrap()).unwrap(); + assert_eq!(integer(&edited, "actionThumbnailTypeKey"), 42); + assert_eq!(integer(&edited, "actionPreconditionVal"), 73); + let (_, original_fields) = RawDescriptor::new(&fixture.entries[1]).unwrap().fields().unwrap(); + let (_, fields) = edited.fields().unwrap(); + let original = RawDescriptor::new(&fixture.entries[1]).unwrap(); + for key in ["futureDouble", "futureActionBytes", "actionPreviewKey"] { + let before = original_fields.iter().find(|field| field.key.is(key)).unwrap(); + let after = fields.iter().find(|field| field.key.is(key)).unwrap(); + assert_eq!(original.source.get(before.span.clone()), edited.source.get(after.span.clone())); + } + explicit_icon(action); + let reset = RawDescriptor::new(&entry(action).unwrap()).unwrap(); + assert_eq!(integer(&reset, "actionThumbnailTypeKey"), 0); + assert!(reset.parsed().unwrap().get("actionIconKey").is_none()); + action.icon = "ClickAction".into(); + let explicit = RawDescriptor::new(&entry(action).unwrap()).unwrap(); + assert_eq!(integer(&explicit, "actionThumbnailTypeKey"), 3); + assert_eq!(explicit.parsed().unwrap().get("actionIconKey"), Some(&D::Text(UnicodeString::new("ClickAction")))); +} + +#[test] +fn clone_and_rename_keep_action_entry_and_assign_set_rebinds_only_set_metadata() { + let fixture = fixture(&["A", "B"], true, "Note\0"); + let mut session = loaded(&fixture.bytes); + let mut clone = session.actions.list[0].clone(); + clone.name = "Clone".into(); + assert_eq!(entry(&clone).unwrap(), fixture.entries[0]); + session.actions.list.push(clone); + session.execute("actions.assignSet", json!({"action": "Clone", "set": "B"})).unwrap(); + let files = saved(&mut session, json!({"action": "Clone"})); + let set = set_metadata(&files, "B"); + assert_eq!(integer(&set, "setColorIndex"), 22); + assert_eq!(set.list("actions").unwrap()[0].bytes(), fixture.entries[0]); + session.execute("actions.assignSet", json!({"action": "Clone", "set": "New"})).unwrap(); + let files = saved(&mut session, json!({"action": "Clone"})); + assert_eq!(integer(&set_metadata(&files, "New"), "setColorIndex"), 0); +} + +#[test] +fn last_action_removal_keeps_live_set_row_but_retired_rows_never_reattach_by_name() { + let fixture = fixture(&["A", "B"], true, "Note\0"); + let mut session = loaded(&fixture.bytes); + session.actions.list.remove(0); + let files = saved(&mut session, json!({"set": "A"})); + assert!(files[0].actions.is_empty()); + assert_eq!(integer(&set_metadata(&files, "A"), "setColorIndex"), 11); + retire_sets(&mut session.actions, &std::collections::HashSet::from(["A".to_string()])); + install_row(&mut session.actions, "A", None); + assert!(groups(&session.actions, Some("A")).unwrap()[0].1.source.is_none()); + let files = saved(&mut session, json!({"set": "B"})); + assert_eq!(integer(&set_metadata(&files, "B"), "setColorIndex"), 22, "surviving row slot was remapped"); +} + +#[test] +fn replacement_retires_only_target_rows_and_keeps_other_set_metadata() { + let mut session = loaded(&fixture(&["A", "B"], true, "Note\0").bytes); + let replacement = fixture(&["A"], false, "Note\0"); + session.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&replacement.bytes)})).unwrap(); + assert_eq!(session.actions.list.len(), 2); + assert_eq!(session.actions.sets.iter().filter(|name| *name == "A").count(), 1); + let files = saved(&mut session, json!({})); + assert_eq!(integer(&set_metadata(&files, "B"), "setColorIndex"), 22); + assert_eq!(integer(&set_metadata(&files, "Empty"), "setColorIndex"), 99); +} + +#[test] +fn incompatible_archive_notes_refuse_export_even_when_skipping_unsupported() { + let mut session = loaded(&fixture(&["A"], false, "First note\0").bytes); + let second = fixture(&["B"], false, "Second note\0"); + session.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&second.bytes), "replace": false})).unwrap(); + let before = session.actions.list.clone(); + for skip in [false, true] { + let error = session.execute("actions.save", json!({"skipUnsupported": skip})).unwrap_err().to_string(); + assert!(error.contains("conflict"), "{error}"); + assert_eq!(session.actions.list, before); + } +} + +#[test] +fn malformed_metadata_or_later_set_never_commits_a_partial_library() { + let fixture = fixture(&["A", "B"], false, "Note\0"); + let mut session = Session::new(); + session.actions.list.push(Action::new("Keep", vec![])); + let before = session.actions.list.clone(); + let mut files = atn::parse_sets(&fixture.bytes).unwrap(); + files[0].actions.clear(); // Deliberately mismatch the independently encoded sidecar. + let invalid = atn::write_sets(&files).unwrap(); + for bytes in [&fixture.bytes[..fixture.bytes.len() - 1], invalid.as_slice()] { + assert!(session.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(bytes)})).is_err()); + assert_eq!(session.actions.list, before); + assert!(session.actions.sets.is_empty()); + } +} + +#[test] +fn similarly_named_user_set_and_unknown_payload_version_remain_visible_and_opaque() { + let mut files = atn::parse_sets(&fixture(&["A"], false, "Note\0").bytes).unwrap(); + files.last_mut().unwrap().actions[0].name = UnicodeString::new_nul("User action"); + let imported = super::super::import(&atn::write_sets(&files).unwrap()).unwrap(); + assert_eq!(imported.sets.len(), 2); + assert_eq!(imported.actions.last().unwrap().steps[0].0, super::super::PHOTOSHOP_STEP); + files.last_mut().unwrap().actions[0].name = UnicodeString::new_nul("Play for details"); + let descriptor = files.last_mut().unwrap().actions[0].items[0].descriptor.as_mut().unwrap(); + let (_, D::RawData(bytes)) = descriptor.items.iter_mut().find(|(key, _)| key.is(MODERN)).unwrap() else { panic!("raw payload") }; + bytes[..4].copy_from_slice(&17u32.to_be_bytes()); + let imported = super::super::import(&atn::write_sets(&files).unwrap()).unwrap(); + assert_eq!(imported.sets.len(), 2); + assert!(imported.actions.iter().all(|action| action.atn_metadata.is_none())); +} + +#[test] +fn new_default_has_no_sidecar_but_description_and_explicit_clickaction_have_canonical_metadata() { + let mut action = Action::new("New", vec![]); + let (bytes, _) = super::super::export("A", &[&action], &[], false).unwrap(); + assert_eq!(atn::parse_sets(&bytes).unwrap().len(), 1); + action.description = "Description".into(); + let (bytes, _) = super::super::export("A", &[&action], &[], false).unwrap(); + let files = atn::parse_sets(&bytes).unwrap(); + let entry = set_metadata(&files, "A").list("actions").unwrap().remove(0); + assert_eq!(integer(&entry, "actionThumbnailTypeKey"), 0); + assert!(entry.parsed().unwrap().get("actionIconKey").is_none()); + action.icon = "ClickAction".into(); + let (bytes, _) = super::super::export("A", &[&action], &[], false).unwrap(); + let files = atn::parse_sets(&bytes).unwrap(); + assert_eq!(integer(&set_metadata(&files, "A").list("actions").unwrap()[0], "actionThumbnailTypeKey"), 3); +} + +#[test] +fn empty_wire_category_and_legacy_none_share_one_group_and_export_empty_name() { + let mut session = loaded(&fixture(&[""], false, "Note\0").bytes); + assert_eq!(session.actions.list[0].set.as_deref(), Some(super::super::UNGROUPED_SET)); + session.actions.list.push(Action::new("Legacy", vec![])); + assert_eq!(crate::actions_edit_cmds::set_names(&session), [super::super::UNGROUPED_SET]); + assert_eq!(groups(&session.actions, None).unwrap().len(), 1); + let old = session.actions.list[0].atn_metadata.clone(); + session.execute("actions.assignSet", json!({"action":0,"set":null})).unwrap(); + assert_eq!(session.actions.list[0].atn_metadata, old); + let files = saved(&mut session, json!({"set": super::super::UNGROUPED_SET})); + assert_eq!(files[0].name.to_string_lossy(), ""); + assert_eq!(files[0].actions.len(), 2); + assert_eq!(integer(&set_metadata(&files, ""), "setColorIndex"), 11); +} + +#[test] +fn literal_reserved_group_name_never_merges_with_empty_wire_category() { + let fixture = fixture(&["", super::super::UNGROUPED_SET, "Ungrouped Actions 2"], false, "Note\0"); + let imported = super::super::import(&fixture.bytes).unwrap(); + assert_eq!(imported.sets.iter().map(|set| set.name.as_str()).collect::>(), ["Ungrouped Actions", "Ungrouped Actions 3", "Ungrouped Actions 2"]); + assert!(!imported.warnings.is_empty()); + assert_eq!(imported.actions[1].atn_metadata.as_ref().unwrap().set.as_ref().unwrap().ordinal, 1); +} + +#[test] +fn duplicate_physical_set_names_keep_distinct_positional_metadata_on_save() { + let mut session = loaded(&fixture(&["A", "A"], false, "Note\0").bytes); + let files = saved(&mut session, json!({"set": "A"})); + assert_eq!(files.len(), 3); + let sets = metadata(&files).list("sets").unwrap(); + assert_eq!(integer(&sets[0], "setColorIndex"), 11); + assert_eq!(integer(&sets[1], "setColorIndex"), 22); + assert_eq!(sets[0].list("actions").unwrap().len(), 1); + assert_eq!(sets[1].list("actions").unwrap().len(), 1); +} + +#[test] +fn sidecar_overhead_is_reserved_before_appending_a_physical_set() { + let mut action = Action::new("Described", vec![]); + action.description = "Details".into(); + let mut files = vec![AtnFile::default(); atn::MAX_SETS]; + let selections: Vec<_> = (0..atn::MAX_SETS).map(|index| Selection { actions: if index == 0 { vec![&action] } else { vec![] }, source: None }).collect(); + assert!(append(&mut files, &selections).is_err()); + assert_eq!(files.len(), atn::MAX_SETS); + + // The move needs one more physical row for S1/S2/S1. Never merge it to fit. + let mut session = loaded(&fixture_rows(&[("A", 2), ("A", 1)], None, "Capacity\0").bytes); + session.execute("actions.move", json!({"action":"Same 3","to":1})).unwrap(); + session.actions.sets.extend((0..atn::MAX_SETS - 3).map(|index| format!("Empty {index}"))); + assert_eq!(session.actions.sets.len(), atn::MAX_SETS - 1); + let before = (session.actions.list.clone(), session.actions.sets.clone(), session.actions.atn_set_rows.clone()); + let prefs = serde_json::to_value(session.prefs()).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("existing.atn"); + std::fs::write(&path, b"keep previous archive").unwrap(); + let error = session.execute("actions.save", json!({"path":path.to_str().unwrap()})).unwrap_err(); + assert!(error.to_string().contains("no space for its metadata sidecar"), "{error}"); + assert_eq!(session.actions.list, before.0); + assert_eq!(session.actions.sets, before.1); + assert_eq!(session.actions.atn_set_rows, before.2); + assert_eq!(serde_json::to_value(session.prefs()).unwrap(), prefs); + assert_eq!(std::fs::read(path).unwrap(), b"keep previous archive"); +} + +#[test] +fn recognized_sidecar_without_real_sets_is_not_silently_discarded() { + let files = atn::parse_sets(&fixture(&[], false, "Only note\0").bytes).unwrap(); + assert!(decode(&files).is_err()); +} + +#[test] +fn native_group_operations_keep_row_carriers_through_copy_move_delete_and_name_reuse() { + let fixture = fixture(&["A", "B"], true, "Original note\0"); + let mut session = loaded(&fixture.bytes); + session.execute("actions.duplicate", json!({"set":"A"})).unwrap(); + session.execute("actions.duplicate", json!({"set":"Empty"})).unwrap(); + let copied = saved(&mut session, json!({"set":"A copy"})); + assert_eq!(integer(&set_metadata(&copied, "A copy"), "setColorIndex"), 11); + assert_eq!(set_metadata(&copied, "A copy").list("actions").unwrap()[0].bytes(), fixture.entries[0]); + let empty_copy = saved(&mut session, json!({"set":"Empty copy"})); + assert!(empty_copy[0].actions.is_empty()); + assert_eq!(integer(&set_metadata(&empty_copy, "Empty copy"), "setColorIndex"), 99); + session.execute("actions.move", json!({"action":"Same 2","to":0})).unwrap(); + let expected: Vec<(String, Vec)> = vec![ + ("B".into(), vec!["Same 2".into()]), + ("A".into(), vec!["Same".into()]), + ("A copy".into(), vec!["Same copy".into()]), + ("Empty".into(), vec![]), + ("Empty copy".into(), vec![]), + ]; + assert_eq!(panel_groups(&session), expected); + let bytes = saved_bytes(&mut session, json!({})); + let moved = atn::parse_sets(&bytes).unwrap(); + assert_eq!(moved[0].name.to_string_lossy(), "B"); + assert_eq!(moved.iter().take(moved.len() - 1).map(|file| file.name.to_string_lossy()).collect::>(), ["B", "A", "A copy", "Empty", "Empty copy"]); + assert_eq!(integer(&set_metadata(&moved, "B"), "setColorIndex"), 22); + assert_eq!(set_metadata(&moved, "B").list("actions").unwrap()[0].bytes(), fixture.entries[1]); + assert_eq!(panel_groups(&loaded(&bytes)), expected); + session.execute("actions.delete", json!({"set":"A"})).unwrap(); + session.execute("actions.delete", json!({"action":"Same copy"})).unwrap(); + let live_empty = saved(&mut session, json!({"set":"A copy"})); + assert!(live_empty[0].actions.is_empty()); + assert_eq!(integer(&set_metadata(&live_empty, "A copy"), "setColorIndex"), 11); + session.execute("actions.delete", json!({"set":"A copy"})).unwrap(); + session.execute("actions.newSet", json!({"name":"A copy"})).unwrap(); + let reused = saved(&mut session, json!({"set":"A copy"})); + assert_eq!(reused.len(), 1, "a reused empty folder has no retired sidecar"); + assert!(reused[0].actions.is_empty()); + for (name, color) in [("B", 22), ("Empty", 99), ("Empty copy", 99)] { + let files = saved(&mut session, json!({"set":name})); + assert_eq!(integer(&set_metadata(&files, name), "setColorIndex"), color, "survivor {name} follows its remapped row"); + } +} + +#[test] +fn duplicating_one_logical_group_copies_each_distinct_physical_set_carrier() { + let fixture = fixture(&["A", "A"], false, "Note\0"); + let mut session = loaded(&fixture.bytes); + session.execute("actions.duplicate", json!({"set":"A"})).unwrap(); + session.execute("actions.delete", json!({"set":"A"})).unwrap(); + let files = saved(&mut session, json!({"set":"A copy"})); + assert_eq!(files.len(), 3); + assert_eq!(files[0].name.to_string_lossy(), "A copy"); + assert_eq!(files[1].name.to_string_lossy(), "A copy"); + let sets = metadata(&files).list("sets").unwrap(); + assert_eq!(sets.len(), 2); + for (index, color) in [(0, 11), (1, 22)] { + assert_eq!(integer(&sets[index], "setColorIndex"), color); + assert_eq!(sets[index].list("actions").unwrap()[0].bytes(), fixture.entries[index]); + } +} + +#[test] +fn actual_editor_icon_update_preserves_unknown_type_until_an_explicit_default_choice() { + let mut session = loaded(&fixture(&["A", "B"], false, "Note\0").bytes); + let opened = session.execute("actions.editor.open", json!({"action":"Same 2"})).unwrap(); + assert_eq!(opened["dirty"], false, "raw NaN metadata clones compare by bytes"); + let id = opened["draft"].as_u64().unwrap(); + session.execute("actions.editor.update", json!({"draft":id,"description":"Edited description"})).unwrap(); + session.execute("actions.editor.save", json!({"draft":id})).unwrap(); + let preserved = saved(&mut session, json!({"action":"Same 2"})); + assert_eq!(integer(&set_metadata(&preserved, "B").list("actions").unwrap()[0], "actionThumbnailTypeKey"), 42); + let reset = session.execute("actions.editor.update", json!({"draft":id,"icon":""})).unwrap(); + assert_eq!(reset["dirty"], true, "an explicit default click differs from leaving an unknown thumbnail alone"); + session.execute("actions.editor.save", json!({"draft":id})).unwrap(); + let reset = saved(&mut session, json!({"action":"Same 2"})); + let entries = set_metadata(&reset, "B").list("actions").unwrap(); + let entry = &entries[0]; + assert_eq!(integer(entry, "actionThumbnailTypeKey"), 0); + assert!(entry.parsed().unwrap().get("actionIconKey").is_none()); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index c7a6bccfa86..51fd2033c05 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -1102,6 +1102,11 @@ impl Session { !self.jobs.running.is_empty() } + /// A recorded step must finish before its owning editor can save or leave. + pub fn has_recording_job(&self) -> bool { + self.jobs.running.iter().any(|job| job.recorded_by == Some(self.actions.take)) + } + /// Running job `id`. pub fn job(&self, id: JobId) -> Option { self.jobs.running.iter().find(|r| r.id == id).map(Running::info) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a463418ef68..364238dc022 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1183,6 +1183,9 @@ pub struct Preferences { /// The Color panel's options (what it shows, its ramp, Web colors, which chip it edits), /// kept across restarts as Photoshop keeps them. JSON owned by the shell. pub color_panel: Value, + /// Actions panel layout and row size, owned and validated by the desktop shell. + /// Null in older settings; live editor and playback state are never stored here. + pub actions_panel: Value, /// The last choices of dialogs that remember them across restarts, by command id (Edit › /// Fill…: `"edit.fill"` → its params). JSON owned by the shell. pub dialogs: BTreeMap, diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs index 3b52c03a037..30449d9b3f3 100644 --- a/crates/engine/src/shell_commands.rs +++ b/crates/engine/src/shell_commands.rs @@ -9,6 +9,8 @@ pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("layer.selectUnderCursor", "Select Layer Under Cursor", &[], None), ("ui.symmetryTransform", "Transform Symmetry", &[], None), + ("actions.layout.modern", "Actions", &[], None), + ("actions.layout.classic", "Classic Actions", &[], 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), diff --git a/crates/engine/tests/it/action_metadata.rs b/crates/engine/tests/it/action_metadata.rs index 26541ed288a..bdefc57cf8c 100644 --- a/crates/engine/tests/it/action_metadata.rs +++ b/crates/engine/tests/it/action_metadata.rs @@ -1,4 +1,4 @@ -//! Native action-library metadata defaults and preservation, independent of ATN sidecars. +//! Action metadata compatibility, editor staging and independent ATN sidecar output checks. use photocraft_engine::{ Session, actions_cmds::{Action, ActionsFile}, @@ -9,6 +9,33 @@ use serde_json::json; const DESCRIPTION: &str = " First line\nCafé 🌿\0 "; const UNKNOWN_ICON: &str = " future/editor-icon-图标 "; +/// Independent PSD descriptor oracle: inspect the emitted sidecar without the Engine's +/// importer or sidecar decoder agreeing with its own writer. +fn assert_exported_metadata(bytes: &[u8], icon: &str) { + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D}; + let files = photocraft_psd::atn::parse_sets(bytes).unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name.to_string_lossy(), "Metadata set"); + assert_eq!(files[0].actions.len(), 1); + assert_eq!(files[0].actions[0].name.to_string_lossy(), "Editable"); + let wrapper = files[1].actions[0].items[0].descriptor.as_ref().unwrap(); + let Some(D::RawData(payload)) = wrapper.get("modernizedActionData") else { panic!("sidecar raw payload missing") }; + assert_eq!(payload.get(..4), Some(16u32.to_be_bytes().as_slice())); + let root = Descriptor::from_bytes(payload.get(4..).unwrap()).unwrap(); + let Some(D::List(sets)) = root.get("sets") else { panic!("sidecar sets missing") }; + assert_eq!(sets.len(), 1); + let D::Descriptor(set) = &sets[0] else { panic!("set descriptor expected") }; + let Some(D::List(actions)) = set.get("actions") else { panic!("sidecar actions missing") }; + assert_eq!(actions.len(), 1); + let D::Descriptor(action) = &actions[0] else { panic!("action descriptor expected") }; + assert_eq!(action.get("actionDescriptionKey"), Some(&D::Text(UnicodeString::new(DESCRIPTION)))); + assert_eq!(action.get("actionIconKey"), Some(&D::Text(UnicodeString::new(icon)))); + assert_eq!(action.get("actionThumbnailTypeKey"), Some(&D::Integer(3))); + assert_eq!(action.get("actionPreconditionVal"), Some(&D::Integer(-1))); + assert_eq!(action.get("actionPreviewKey"), Some(&D::Boolean(false))); + assert_eq!(action.get("actionGenerativeKey"), Some(&D::Boolean(false))); +} + fn described(name: &str, icon: &str) -> Action { let mut action = Action::new(name, vec![("future.step".into(), json!({"raw": [0, 255], "nested": {"keep": true}}))]); action.description = DESCRIPTION.into(); @@ -65,7 +92,7 @@ fn cloned_actions_keep_metadata_through_rename_and_real_preset_reload() { let mut session = Session::new(); assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); assert_eq!(session.actions.list, initial.actions); - // This source has no actions.duplicate command. Exercise model duplication without claiming one. + // Exercise the model clone here; native duplication has its own regression tests. let mut copy = session.actions.list[1].clone(); copy.name = "Copy".into(); assert_eq!(copy.description, DESCRIPTION); @@ -140,19 +167,28 @@ fn editor_metadata_stays_draft_until_save_and_discard_keeps_saved_values() { let mut reopened = Session::new(); assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); assert_eq!(reopened.actions.list, native.actions); - // Before sidecar writing exists, export must refuse metadata rather than silently lose it. + // Metadata now exports through the real sidecar, including an unknown glyph identifier. let committed = session.actions.list.clone(); let mut plain = original.clone(); plain.description.clear(); plain.icon.clear(); for skip in [false, true] { - assert!(session.execute("actions.save", json!({"action": "Editable", "set": "Metadata set", "skipUnsupported": skip})).is_err()); + let exported = session.execute("actions.save", json!({"action": "Editable", "set": "Metadata set", "skipUnsupported": skip})).unwrap(); + assert!(exported["skipped"].as_array().unwrap().is_empty()); + let bytes = photocraft_engine::paint::tile::b64_decode(exported["data"].as_str().unwrap()).unwrap(); + assert_exported_metadata(&bytes, icon); let (bytes, skipped) = photocraft_engine::atn_cmds::export("Metadata set", &[&plain], &[], skip).unwrap(); assert!(!bytes.is_empty()); assert!(skipped.is_empty()); assert_eq!(session.actions.list, committed); assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), saved_bytes); } + let mut unsupported = original.clone(); + unsupported.steps.push(("future.step".into(), json!({"raw": [0,255]}))); + assert!(photocraft_engine::atn_cmds::export("Metadata set", &[&unsupported], &[], false).is_err()); + let (bytes, skipped) = photocraft_engine::atn_cmds::export("Metadata set", &[&unsupported], &[], true).unwrap(); + assert_eq!(skipped.len(), 1, "skipUnsupported affects steps, never metadata"); + assert_exported_metadata(&bytes, icon); session.execute("actions.editor.update", json!({"draft": draft, "description": "Discard me", "icon": "UncommittedIcon"})).unwrap(); session.execute("actions.editor.discard", json!({"draft": draft})).unwrap(); assert_eq!(session.actions.list, vec![original]); diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs new file mode 100644 index 00000000000..31907b7ceda --- /dev/null +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -0,0 +1,125 @@ +//! Duplicate keeps action identities, complete settings and per-step flags stable. +use photocraft_engine::{Session, actions_cmds::Action}; +use serde_json::{Value, json}; + +/// A tiny document to distinguish definition edits from document mutations and playback. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + s +} + +/// Copies retain metadata and call targets without taking the original function key. +#[test] +fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { + let mut s = session(); + let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("layer.select".into(), json!({"layer": "Photo"}))]); + source.disabled.insert(1); + source.dialogs.insert(0); + source.set = Some("Retouch".into()); + source.color = "red".into(); + let foreign = json!({"actions": [{"name": "Foreign", "steps": [["actions.play", {"action": 1}]]}]}); + s.actions.list = vec![ + source.clone(), + Action::new("Target", vec![("select.all".into(), json!({}))]), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action": 1})), + ("actions.play".into(), json!({"action": 3})), // Unresolved before and after insertion. + ("actions.play".into(), json!({"action": 1.0})), + ("file.automate.batch".into(), foreign.clone()), + ("actions.conditional".into(), json!({"if": "always", "then": "Target", "else": "Source"})), + ], + ), + ]; + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:Source".into(), "F6".into()); + }); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": "Source"})).unwrap(); + assert_eq!(result["index"], 1); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Source", "Source copy", "Target", "Caller"]); + let mut expected = source; + expected.name = "Source copy".into(); + expected.steps[0].1["action"] = json!(2); + assert_eq!(s.actions.list[1], expected); + assert_eq!(s.actions.list[0].steps[0].1["action"], 2); + let caller = &s.actions.list[3]; + assert_eq!(caller.steps[0].1["action"], 2); + assert_eq!(caller.steps[1].1["action"], 4); + assert_eq!(caller.steps[2].1["action"], 2.0); + assert_eq!(caller.steps[3].1, foreign, "embedded libraries keep their own indices"); + assert_eq!(caller.steps[4].1, json!({"if": "always", "then": "Target", "else": "Source"})); + assert_eq!(s.prefs().shortcuts.get("actions.play:Source").map(String::as_str), Some("F6")); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Source copy")); + assert_eq!(s.active().unwrap().revision, revision); + let played = s.execute("actions.play", json!({"action": "Source copy"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(s.active().unwrap().doc.selection.is_some(), "the copied call still plays Target rather than itself"); + s.execute("actions.duplicate", json!({"action": 0})).unwrap(); + assert_eq!(s.actions.list[1].name, "Source copy 2"); + assert_eq!(s.actions.list[2].name, "Source copy"); + assert_eq!(s.actions.list[1].steps[0].1["action"], 3); +} + +/// Step copies own their settings and retain both flag columns after insertion. +#[test] +fn step_copies_preserve_payloads_and_shift_both_flag_columns() { + let mut s = session(); + let payload = json!({"layer": "Photo", "_actionLayer": {"name": "Photo", "ordinal": 2}}); + let mut a = Action::new("Steps", vec![("layer.select".into(), payload.clone()), ("select.all".into(), Value::Null), ("select.deselect".into(), json!({}))]); + a.disabled.extend([0, 2]); + a.dialogs.extend([0, 1]); + s.actions.list.push(a); + let revision = s.active().unwrap().revision; + let result = s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(result["step"], 1); + assert_eq!(result["index"], 0); + let a = &s.actions.list[0]; + assert_eq!(a.name, "Steps"); + assert_eq!(a.steps[0], a.steps[1]); + assert_eq!(a.disabled.iter().copied().collect::>(), [0, 1, 3]); + assert_eq!(a.dialogs.iter().copied().collect::>(), [0, 1, 2]); + s.execute("actions.duplicate", json!({"action": 0, "step": 2})).unwrap(); + assert!(s.actions.list[0].steps[2].1.is_null()); + assert!(s.actions.list[0].steps[3].1.is_null(), "copying does not normalize null settings to an empty object"); + s.actions.list[0].steps[1].1["layer"] = json!(99); + assert_eq!(s.actions.list[0].steps[0].1, payload, "the copy owns its settings"); + assert_eq!(s.active().unwrap().revision, revision); +} + +/// Refusals preserve pending recording; valid step copies keep recording usable. +#[test] +fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() { + let mut s = session(); + s.execute("actions.record", json!({"name": "Recording"})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + let before = s.actions.list.clone(); + let recording = s.actions.recording; + let revision = s.active().unwrap().revision; + for params in [ + json!({}), + json!({"action": 9}), + json!({"action": 0}), + json!({"action": 0, "step": -1}), + json!({"action": 0, "step": 5}), + json!({"action": 0, "step": "x"}), + ] { + assert!(s.execute("actions.duplicate", params.clone()).is_err(), "{params}"); + assert_eq!(s.actions.list, before); + assert_eq!(s.actions.recording, recording); + assert_eq!(s.active().unwrap().revision, revision); + } + s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap(); + assert_eq!(s.actions.list[0].steps, vec![("select.all".into(), json!({})); 2]); + assert!(s.actions.recording.is_some()); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + assert_eq!(s.actions.list[0].steps.len(), 3); + s.actions.list.push(Action::new("Edit during playback", vec![("actions.duplicate".into(), json!({"action": 0}))])); + let before = s.actions.list.clone(); + let played = s.execute("actions.play", json!({"action": "Edit during playback"})).unwrap(); + assert!(played["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{played}"); + assert_eq!(s.actions.list, before); +} diff --git a/crates/engine/tests/it/actions_group_delete.rs b/crates/engine/tests/it/actions_group_delete.rs new file mode 100644 index 00000000000..3aea3f3ba73 --- /dev/null +++ b/crates/engine/tests/it/actions_group_delete.rs @@ -0,0 +1,133 @@ +//! Folder edits are atomic definition changes, preserving surviving call identities. +use photocraft_engine::{ + Session, + actions_cmds::{Action, playback}, +}; +use serde_json::{Value, json}; + +fn grouped(name: &str, set: &str) -> Action { + Action { set: Some(set.into()), ..Action::new(name, Vec::new()) } +} + +#[test] +fn delete_group_removes_contents_keys_and_retargets_only_surviving_local_calls() { + let mut s = Session::new(); + let foreign = json!({"actions":[{"steps":[["actions.play",{"action":1}]]}]}); + s.actions.list = vec![ + grouped("Removed one", "Group"), + grouped("Survivor", "Other"), + grouped("Removed two", "Group"), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action":0})), + ("actions.play".into(), json!({"action":1.2})), + ("actions.play".into(), json!({"action":2})), + ("actions.play".into(), json!({"action":3})), + ("actions.play".into(), json!({"action":99})), + ("actions.play".into(), json!({"action":"Removed two"})), + ("actions.conditional".into(), json!({"if":"always","then":"Removed one","else":"Survivor"})), + ("file.automate.batch".into(), foreign.clone()), + ], + ), + ]; + s.actions.sets = vec!["Group".into(), "Empty".into()]; + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:Removed one".into(), "F2".into()); + p.alternate_shortcuts.insert("actions.play:Removed two".into(), vec!["F3".into()]); + p.shortcuts.insert("actions.play:Survivor".into(), "F4".into()); + }); + let result = s.execute("actions.delete", json!({"set":"Group"})).unwrap(); + assert_eq!(result, json!({"deletedSet":"Group","deleted":["Removed one","Removed two"]})); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Survivor", "Caller"]); + assert_eq!(s.actions.sets, ["Empty"]); + let steps = &s.actions.list[1].steps; + assert!(steps[0].1["action"].is_null()); + assert_eq!(steps[1].1["action"], 0); + assert!(steps[2].1["action"].is_null()); + assert_eq!(steps[3].1["action"], 1); + assert_eq!(steps[4].1["action"], 99); + assert!(steps[5].1["action"].is_null()); + assert_eq!(steps[6].1, json!({"if":"always","then":null,"else":"Survivor"})); + assert_eq!(steps[7].1, foreign); + assert!(!s.prefs().shortcuts.contains_key("actions.play:Removed one")); + assert!(!s.prefs().alternate_shortcuts.contains_key("actions.play:Removed two")); + assert_eq!(s.prefs().shortcuts["actions.play:Survivor"], "F4"); + // A later reuse of either a name or an index must not revive a deleted call. + s.actions.list.push(Action::new("Removed one", Vec::new())); + assert!(s.execute("actions.play", json!({"action":Value::Null})).is_err()); + assert_eq!(s.execute("actions.delete", json!({"set":"Empty"})).unwrap()["deleted"], json!([])); + assert!(s.actions.sets.is_empty()); +} + +#[test] +fn invalid_group_delete_and_recording_or_preview_guards_leave_definitions_unchanged() { + let mut s = Session::new(); + s.actions.list.push(grouped("A", "Group")); + s.actions.sets.push("Empty".into()); + for params in [json!({"set":null}), json!({"set":2}), json!({"set":"Missing"}), json!({"set":"Group","action":0}), json!({"set":"Group","step":0})] { + let before = s.actions.clone(); + assert!(s.execute("actions.delete", params).is_err()); + assert_eq!(s.actions, before); + } + s.execute("actions.record", json!({"action":"A"})).unwrap(); + let before = s.actions.clone(); + assert!(s.execute("actions.delete", json!({"set":"Group"})).is_err()); + assert_eq!(s.actions, before); + s.execute("actions.stop", json!({})).unwrap(); + let id = s.execute("actions.editor.open", json!({"action":"A"})).unwrap()["draft"].as_u64().unwrap(); + s.execute("actions.editor.record", json!({"draft":id})).unwrap(); + let before = s.actions.clone(); + for command in ["actions.delete", "actions.duplicate"] { + assert!(s.execute(command, json!({"set":"Group"})).is_err()); + assert_eq!(s.actions, before); + } + let (_, owner) = playback::preview(&mut s, id, 0).unwrap(); + let before = s.actions.clone(); + for command in ["actions.delete", "actions.duplicate"] { + assert!(s.execute(command, json!({"set":"Group"})).is_err()); + assert_eq!(s.actions, before); + } + owner.finish(&mut s); + assert_eq!(s.execute("actions.editor.get", json!({"draft":id})).unwrap()["recording"], true); +} + +#[test] +fn duplicate_group_preserves_complete_actions_and_keeps_original_shortcuts() { + let mut s = Session::new(); + let mut source = grouped("A", "Group"); + source.icon = "FutureIcon".into(); + source.description = "Retain metadata".into(); + source.steps.push(("actions.play".into(), json!({"action":1}))); + source.disabled.insert(0); + source.dialogs.insert(0); + s.actions.list = vec![source.clone(), grouped("Target", "Other")]; + s.actions.sets.push("Empty".into()); + s.prefs.edit(|p| { + p.shortcuts.insert("actions.play:A".into(), "F5".into()); + }); + let result = s.execute("actions.duplicate", json!({"set":"Group"})).unwrap(); + assert_eq!(result["set"], "Group copy"); + source.name = "A copy".into(); + source.set = Some("Group copy".into()); + source.steps[0].1["action"] = json!(2); + assert_eq!(s.actions.list[1], source); + assert_eq!(s.prefs().shortcuts["actions.play:A"], "F5"); + assert!(!s.prefs().shortcuts.contains_key("actions.play:A copy")); + assert_eq!(s.execute("actions.duplicate", json!({"set":"Empty"})).unwrap()["actions"], json!([])); + assert!(s.actions.sets.iter().any(|name| name == "Empty copy")); +} + +#[test] +fn ungrouped_group_commands_share_one_identity_for_legacy_none_and_explicit_category() { + let mut s = Session::new(); + s.actions.list = vec![Action::new("Legacy", Vec::new()), grouped("Explicit", photocraft_engine::atn_cmds::UNGROUPED_SET), grouped("Other", "Named")]; + let groups = photocraft_engine::actions_edit_cmds::set_names(&s); + assert_eq!(groups, [photocraft_engine::atn_cmds::UNGROUPED_SET, "Named"]); + let copied = s.execute("actions.duplicate", json!({"set":photocraft_engine::atn_cmds::UNGROUPED_SET})).unwrap(); + assert_eq!(copied["actions"].as_array().unwrap().len(), 2); + let deleted = s.execute("actions.delete", json!({"set":photocraft_engine::atn_cmds::UNGROUPED_SET})).unwrap(); + assert_eq!(deleted["deleted"], json!(["Legacy", "Explicit"])); + assert_eq!(s.actions.list.len(), 3); + assert!(!photocraft_engine::actions_edit_cmds::set_names(&s).contains(&photocraft_engine::atn_cmds::UNGROUPED_SET.to_string())); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index a5428b22eeb..20493de805b 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,7 +1,9 @@ //! The crate's integration tests, linked into one test binary. mod action_metadata; +mod actions_duplicate; mod actions_editor_draft; +mod actions_group_delete; mod actions_owned_preview; mod affinity; mod artboard_export_names; diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 0e5e08d6991..74d693a1d57 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -1,4 +1,4 @@ -//! Photoshop Actions files (`.atn`): one action set, its actions and their steps. +//! Photoshop Actions files (`.atn`): action sets, their actions and their steps. //! //! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions* //! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was @@ -6,7 +6,8 @@ //! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. //! //! ```text -//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action +//! file := version u32 (12 or 16), set (version 16 may hold further sets until EOF) +//! set := name UnicodeString, expanded u8, count u32, count × action //! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, //! name UnicodeString, expanded u8, count u32, count × item //! item := expanded u8, enabled u8, with dialog u8, dialog options u8, @@ -31,8 +32,9 @@ //! //! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor //! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so -//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files -//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout. +//! `parse(write(f)) == f` for sets [`parse`] read. The existing single-set API retains trailing-byte +//! warnings. [`parse_sets`] and [`write_sets`] preserve complete version-16 multi-set files with +//! one version header; a version-12 file's legacy descriptors keep their meaning, not their layout. use crate::codepage; use crate::descriptor::{Descriptor, UnicodeString}; @@ -40,6 +42,9 @@ use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt, decode_legacy_name}; mod legacy; +mod sets; + +pub use sets::{MAX_SETS, MAX_TOTAL_ACTIONS, MAX_TOTAL_ITEMS, parse_sets, parse_sets_with_encoding, write_sets}; /// The version written by this module (Photoshop 6 and every later release). pub const VERSION: u32 = 16; @@ -237,7 +242,7 @@ fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { } /// Read an action and its steps using the file's descriptor dialect. -fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { +fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>) -> Result { let function_key = r.u16()?; let shift = r.u8()? != 0; let command = r.u8()? != 0; @@ -250,6 +255,9 @@ fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { if n as usize > MAX_ITEMS { return Err(PsdError::LimitExceeded("too many steps in one action")); } + if let Some(budget) = budget { + budget.items(n as usize)?; + } let mut items = Vec::with_capacity((n as usize).min(1024)); for _ in 0..n { items.push(read_item(r, legacy)?); @@ -278,11 +286,24 @@ pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result Result { let mut r = Reader::new(data); + let version = read_version(&mut r)?; + let mut file = read_set(&mut r, version == 12, None)?; + if !r.is_empty() { + file.warnings.push(format!("{} trailing bytes ignored", r.remaining())); + } + Ok(file) +} + +fn read_version(r: &mut Reader<'_>) -> Result { let version = r.u32()?; if !matches!(version, 12 | VERSION) { return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)"))); } - let name = UnicodeString::read(&mut r)?; + Ok(version) +} + +fn read_set(r: &mut Reader<'_>, legacy: bool, mut budget: Option<&mut sets::Budget>) -> Result { + let name = UnicodeString::read(r)?; let expanded = r.u8()? != 0; let n = r.u32()?; // Each action needs at least its key, modifiers, colour, an empty name, the flag and a count. @@ -290,21 +311,24 @@ fn parse_layout(data: &[u8]) -> Result { if n as usize > MAX_ACTIONS { return Err(PsdError::LimitExceeded("too many actions in one set")); } + if let Some(budget) = budget.as_deref_mut() { + budget.actions(n as usize)?; + } let mut actions = Vec::with_capacity((n as usize).min(256)); for _ in 0..n { - actions.push(read_action(&mut r, version == 12)?); + actions.push(read_action(r, legacy, budget.as_deref_mut())?); } - let mut warnings = Vec::new(); - if !r.is_empty() { - warnings.push(format!("{} trailing bytes ignored", r.remaining())); - } - Ok(AtnFile { name, expanded, actions, warnings, encoding: None }) + Ok(AtnFile { name, expanded, actions, warnings: Vec::new(), encoding: None }) } /// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown /// from them needs one; adds a warning when the guess is uncertain. fn name_encoding(f: &AtnFile, warnings: &mut Vec) -> Option<&'static encoding_rs::Encoding> { - let items = || f.actions.iter().flat_map(|a| &a.items); + name_encoding_sets(std::slice::from_ref(f), warnings) +} + +fn name_encoding_sets(files: &[AtnFile], warnings: &mut Vec) -> Option<&'static encoding_rs::Encoding> { + let items = || files.iter().flat_map(|file| &file.actions).flat_map(|action| &action.items); let legacy = |b: &[u8]| !b.is_ascii(); // Steps that store their name as Unicode too: the encoding that turns one into the other. let pairs: Vec<(&[u8], String)> = items() @@ -333,13 +357,23 @@ fn name_encoding(f: &AtnFile, warnings: &mut Vec) -> Option<&'static enc return None; } // The set, action and class names are UTF-16: their script points at the writer's locale. - let mut hint = f.name.to_string_lossy(); - for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) { - if hint.len() > codepage::GUESS_SAMPLE { - break; + let mut hint = String::new(); + for (index, file) in files.iter().enumerate() { + if index == 0 { + hint = file.name.to_string_lossy(); + } else if hint.len() <= codepage::GUESS_SAMPLE { + hint.push('\n'); + hint.push_str(&file.name.to_string_lossy()); + } + for name in + file.actions.iter().map(|action| action.name.to_string_lossy()).chain(file.actions.iter().flat_map(|action| &action.items).filter_map(class_name)) + { + if hint.len() > codepage::GUESS_SAMPLE { + break; + } + hint.push('\n'); + hint.push_str(&name); } - hint.push('\n'); - hint.push_str(&name); } let guess = codepage::guess(&orphans, &hint); if guess.uncertain { @@ -385,7 +419,12 @@ fn count(n: usize, what: &'static str) -> Result { pub fn write(f: &AtnFile) -> Result> { let mut out = Vec::new(); out.put_u32(VERSION); - f.name.write(&mut out); + write_set(&mut out, f)?; + Ok(out) +} + +fn write_set(out: &mut Vec, f: &AtnFile) -> Result<()> { + f.name.write(out); out.put_u8(u8::from(f.expanded)); out.put_u32(count(f.actions.len(), "too many actions")?); for a in &f.actions { @@ -393,14 +432,14 @@ pub fn write(f: &AtnFile) -> Result> { out.put_u8(u8::from(a.shift)); out.put_u8(u8::from(a.command)); out.put_u16(a.color); - a.name.write(&mut out); + a.name.write(out); out.put_u8(u8::from(a.expanded)); out.put_u32(count(a.items.len(), "too many steps")?); for item in &a.items { - write_item(&mut out, item)?; + write_item(out, item)?; } } - Ok(out) + Ok(()) } #[cfg(test)] diff --git a/crates/psd/src/atn/sets.rs b/crates/psd/src/atn/sets.rs new file mode 100644 index 00000000000..53bb93b8d76 --- /dev/null +++ b/crates/psd/src/atn/sets.rs @@ -0,0 +1,105 @@ +//! Whole-file version-16 set streams. The original single-set/legacy API remains compatible. +use super::{AtnFile, MAX_ACTIONS, MAX_ITEMS, VERSION, codepage, name_encoding_sets, read_set, read_version, write_set}; +use crate::{ + error::{PsdError, Result}, + io::{Reader, WriteExt}, +}; + +/// Whole-archive resource ceilings, not Photoshop format maxima. Per-set/action ceilings still apply. +pub const MAX_SETS: usize = MAX_ACTIONS; +/// Maximum action headers across the complete archive, including any metadata sidecar. +pub const MAX_TOTAL_ACTIONS: usize = MAX_ACTIONS; +/// Maximum steps across the complete archive, including any metadata sidecar. +pub const MAX_TOTAL_ITEMS: usize = MAX_ITEMS; + +#[derive(Default)] +pub(super) struct Budget { + actions: usize, + items: usize, +} + +impl Budget { + pub(super) fn actions(&mut self, count: usize) -> Result<()> { + if count > MAX_ACTIONS { + return Err(PsdError::LimitExceeded("too many actions in one set")); + } + self.actions = + self.actions.checked_add(count).filter(|sum| *sum <= MAX_TOTAL_ACTIONS).ok_or(PsdError::LimitExceeded("too many actions in actions archive"))?; + Ok(()) + } + + pub(super) fn items(&mut self, count: usize) -> Result<()> { + if count > MAX_ITEMS { + return Err(PsdError::LimitExceeded("too many steps in one action")); + } + self.items = self.items.checked_add(count).filter(|sum| *sum <= MAX_TOTAL_ITEMS).ok_or(PsdError::LimitExceeded("too many steps in actions archive"))?; + Ok(()) + } +} + +/// Read every set in a version-16 file, in order, with one version prefix and strict EOF. +/// A malformed later set fails the whole result. Version12 retains the existing one-set/tail contract. +pub fn parse_sets(data: &[u8]) -> Result> { + parse_sets_with_encoding(data, None) +} + +/// [`parse_sets`] with one legacy name encoding for the complete file. Automatic detection also +/// uses the complete stream; its diagnostic is attached once to the first set. +pub fn parse_sets_with_encoding(data: &[u8], encoding: Option<&str>) -> Result> { + let forced = encoding.map(codepage::resolve_label).transpose()?; + let mut reader = Reader::new(data); + let version = read_version(&mut reader)?; + let legacy = version == 12; + let mut files = Vec::new(); + let mut budget = Budget::default(); + loop { + if files.len() >= MAX_SETS { + return Err(PsdError::LimitExceeded("too many sets in actions archive")); + } + let before = reader.remaining(); + let mut file = read_set(&mut reader, legacy, Some(&mut budget))?; + if legacy && !reader.is_empty() { + file.warnings.push(format!("{} trailing bytes ignored", reader.remaining())); + } + if reader.remaining() >= before { + return Err(PsdError::invalid("actions set made no progress")); + } + files.push(file); + if legacy || reader.is_empty() { + break; + } + } + let mut warnings = Vec::new(); + let encoding = forced.or_else(|| name_encoding_sets(&files, &mut warnings)).map(|encoding| encoding.name().to_string()); + for file in &mut files { + file.encoding = encoding.clone(); + } + if let Some(first) = files.first_mut() { + first.warnings.extend(warnings); + } + Ok(files) +} + +/// Write all sets under one version16 prefix. Validate aggregate counts before allocating output. +/// At least one set is required; an empty set is valid. Stored names/descriptors stay verbatim. +pub fn write_sets(files: &[AtnFile]) -> Result> { + if files.is_empty() { + return Err(PsdError::invalid("actions archive must contain at least one set")); + } + if files.len() > MAX_SETS { + return Err(PsdError::LimitExceeded("too many sets in actions archive")); + } + let mut budget = Budget::default(); + for file in files { + budget.actions(file.actions.len())?; + for action in &file.actions { + budget.items(action.items.len())?; + } + } + let mut out = Vec::new(); + out.put_u32(VERSION); + for file in files { + write_set(&mut out, file)?; + } + Ok(out) +} diff --git a/crates/psd/tests/atn_sidecar.rs b/crates/psd/tests/atn_sidecar.rs new file mode 100644 index 00000000000..1430e256f9a --- /dev/null +++ b/crates/psd/tests/atn_sidecar.rs @@ -0,0 +1,233 @@ +//! Independently specified version16 wire streams: one header, consecutive set bodies. +use photocraft_psd::{atn, descriptor::Value, error::PsdError}; +use proptest::prelude::*; + +fn u32be(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_be_bytes()); +} +fn unicode(out: &mut Vec, units: &[u16]) { + u32be(out, units.len() as u32); + for unit in units { + out.extend_from_slice(&unit.to_be_bytes()); + } +} +fn text(out: &mut Vec, value: &str) { + unicode(out, &value.encode_utf16().collect::>()); +} +fn id(out: &mut Vec, key: &str) { + if key.len() == 4 { + u32be(out, 0); + } else { + u32be(out, key.len() as u32); + } + out.extend_from_slice(key.as_bytes()); +} +fn descriptor(items: &[(&str, &[u8; 4], Vec)]) -> Vec { + let mut out = Vec::new(); + unicode(&mut out, &[]); + id(&mut out, "null"); + u32be(&mut out, items.len() as u32); + for (key, kind, value) in items { + id(&mut out, key); + out.extend_from_slice(*kind); + out.extend(value); + } + out +} +fn list(descriptors: &[Vec]) -> Vec { + let mut out = Vec::new(); + u32be(&mut out, descriptors.len() as u32); + for descriptor in descriptors { + out.extend_from_slice(b"Objc"); + out.extend(descriptor); + } + out +} +fn string(value: &str) -> Vec { + let mut out = Vec::new(); + text(&mut out, value); + out +} +fn set_header(out: &mut Vec, name: &[u16], actions: u32) { + unicode(out, name); + out.push(0); + u32be(out, actions); +} +fn action_header(out: &mut Vec, name: &str, items: u32) { + out.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // Fn, Shift, Control and colour. + text(out, name); + out.push(0); + u32be(out, items); +} +fn bare_item(out: &mut Vec) { + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(b"longInvr"); + u32be(out, 0); + u32be(out, 0); +} + +fn fixture() -> (Vec, usize, Vec, Vec) { + let metadata = descriptor(&[ + ("actionDescriptionKey", b"TEXT", string("Description\0")), + ("actionThumbnailTypeKey", b"long", 3i32.to_be_bytes().to_vec()), + ("actionIconKey", b"TEXT", string("future-icon\0")), + ("actionPreviewKey", b"bool", vec![0]), + ("actionGenerativeKey", b"bool", vec![0]), + ("actionPreconditionVal", b"long", (-1i32).to_be_bytes().to_vec()), + ("futureDouble", b"doub", 0x7ff8_0000_0000_0042u64.to_be_bytes().to_vec()), + ]); + let first = descriptor(&[("setColorIndex", b"long", 7i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[metadata]))]); + let empty = descriptor(&[("setColorIndex", b"long", 19i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[]))]); + let root = descriptor(&[("sets", b"VlLs", list(&[first, empty])), ("futureRaw", b"tdta", vec![0, 0, 0, 3, 0, 255, 7])]); + let mut payload = 16u32.to_be_bytes().to_vec(); + payload.extend(root); + let mut raw = Vec::new(); + u32be(&mut raw, payload.len() as u32); + raw.extend(&payload); + let outer = descriptor(&[("Msge", b"TEXT", string("Original note\0")), ("Cntn", b"bool", vec![1]), ("modernizedActionData", b"tdta", raw)]); + let mut bytes = 16u32.to_be_bytes().to_vec(); + set_header(&mut bytes, &"Real\0".encode_utf16().collect::>(), 1); + action_header(&mut bytes, "Invert\0", 1); + bare_item(&mut bytes); + let first_end = bytes.len(); + set_header(&mut bytes, &[0xd800, 0], 0); // Empty real set and unpaired UTF16 remain exact. + set_header(&mut bytes, &"Action sidecar\0".encode_utf16().collect::>(), 1); + action_header(&mut bytes, "Play for details\0", 1); + bytes.extend_from_slice(&[0, 1, 1, 2]); + bytes.extend_from_slice(b"TEXT"); + u32be(&mut bytes, 4); + bytes.extend_from_slice(b"stop"); + u32be(&mut bytes, 4); + bytes.extend_from_slice(b"Stop"); + bytes.extend_from_slice(&(-1i32).to_be_bytes()); + bytes.extend(&outer); + (bytes, first_end, outer, payload) +} + +#[test] +fn one_version_and_three_set_bodies_preserve_raw_sidecar_descriptor_separately() { + let (bytes, first_end, outer, payload) = fixture(); + assert_eq!(bytes.get(first_end..first_end + 4).unwrap(), &2u32.to_be_bytes()); + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(sets.len(), 3); + assert_eq!(sets[0].name.to_string_lossy(), "Real"); + assert_eq!(sets[1].name.0, vec![0xd800, 0]); + assert!(sets[1].actions.is_empty()); + let sidecar = sets[2].actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(sidecar.to_bytes(), outer, "descriptor bytes have their own independent oracle"); + assert_eq!(sidecar.get("modernizedActionData"), Some(&Value::RawData(payload))); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + let single = atn::parse(&bytes).unwrap(); + assert_eq!(single.name, sets[0].name); + assert_eq!(single.warnings, [format!("{} trailing bytes ignored", bytes.len() - first_end)]); +} + +#[test] +fn every_truncated_later_set_fails_without_returning_partial_sets() { + let (bytes, first_end, _, _) = fixture(); + // The first complete set alone is valid. The second complete empty set is another valid prefix. + let second_end = first_end + 13; + for cut in first_end + 1..bytes.len() { + if cut != second_end { + assert!(atn::parse_sets(&bytes[..cut]).is_err(), "cut {cut}"); + } + } + assert_eq!(atn::parse_sets(&bytes[..first_end]).unwrap().len(), 1); + assert_eq!(atn::parse_sets(&bytes[..second_end]).unwrap().len(), 2); + let mut repeated = bytes[..first_end].to_vec(); + repeated.extend_from_slice(&16u32.to_be_bytes()); + set_header(&mut repeated, &[], 0); + assert!(atn::parse_sets(&repeated).is_err(), "a repeated version is not the measured set boundary"); + assert!(atn::parse_sets(&16u32.to_be_bytes()).is_err()); + assert!(atn::write_sets(&[]).is_err()); +} + +#[test] +fn forced_encoding_applies_to_all_sets_and_legacy_tails_remain_compatible() { + let (bytes, _, _, _) = fixture(); + let sets = atn::parse_sets_with_encoding(&bytes, Some("shift_jis")).unwrap(); + assert!(sets.iter().all(|set| set.encoding.as_deref() == Some("Shift_JIS"))); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + assert!(atn::parse_sets_with_encoding(&bytes, Some("utf-16")).is_err()); + let mut legacy = 12u32.to_be_bytes().to_vec(); + set_header(&mut legacy, &[], 0); + legacy.extend_from_slice(&[99, 1, 4]); + let old = atn::parse(&legacy).unwrap(); + assert_eq!(atn::parse_sets(&legacy).unwrap(), vec![old.clone()]); + assert_eq!(old.warnings, ["3 trailing bytes ignored"]); +} + +#[test] +fn whole_archive_set_and_action_budgets_are_checked_across_boundaries() { + let mut bytes = 16u32.to_be_bytes().to_vec(); + for _ in 0..atn::MAX_SETS { + set_header(&mut bytes, &[], 0); + } + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(sets.len(), atn::MAX_SETS); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + set_header(&mut bytes, &[], 0); + assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_)))); + let mut excess = sets; + excess.push(Default::default()); + assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_)))); + + let mut bytes = 16u32.to_be_bytes().to_vec(); + for _ in 0..2 { + set_header(&mut bytes, &[], (atn::MAX_TOTAL_ACTIONS / 2) as u32); + for _ in 0..atn::MAX_TOTAL_ACTIONS / 2 { + action_header(&mut bytes, "", 0); + } + } + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(sets.iter().map(|set| set.actions.len()).sum::(), atn::MAX_TOTAL_ACTIONS); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + set_header(&mut bytes, &[], 1); + action_header(&mut bytes, "", 0); + assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_)))); +} + +#[test] +fn aggregate_steps_are_bounded_before_a_later_action_allocates() { + let mut bytes = 16u32.to_be_bytes().to_vec(); + set_header(&mut bytes, &[], 2); + for _ in 0..2 { + action_header(&mut bytes, "", (atn::MAX_TOTAL_ITEMS / 2) as u32); + for _ in 0..atn::MAX_TOTAL_ITEMS / 2 { + bare_item(&mut bytes); + } + } + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + set_header(&mut bytes, &[], 1); + action_header(&mut bytes, "", 1); + bare_item(&mut bytes); + assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_)))); + let mut excess = sets; + excess[0].actions[0].items.push(Default::default()); + assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_)))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn arbitrary_streams_reject_or_reach_a_stable_writer(data in prop::collection::vec(any::(), 0..2048)) { + if let Ok(files) = atn::parse_sets(&data) { + let encoded = atn::write_sets(&files).unwrap(); + let again = atn::parse_sets(&encoded).unwrap(); + prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded); + } + } + + #[test] + fn mutated_independent_multiset_stream_rejects_or_reaches_a_stable_writer(index in any::(), value in any::()) { + let (mut bytes, _, _, _) = fixture(); + let at = index % bytes.len(); bytes[at] = value; + if let Ok(files) = atn::parse_sets(&bytes) { + let encoded = atn::write_sets(&files).unwrap(); + let again = atn::parse_sets(&encoded).unwrap(); + prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded); + } + } +} diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index a90d4903adf..1d35995a7ec 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -17,7 +17,12 @@ use std::fmt::Write as _; use serde_json::Value; fn main() { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors"); + let Some(manifest_dir) = std::env::var_os("CARGO_MANIFEST_DIR").filter(|path| !path.is_empty()) else { + println!("cargo::error=CARGO_MANIFEST_DIR is missing; cannot generate source-bound UI assets"); + return; + }; + let manifest_dir = std::path::PathBuf::from(manifest_dir); + let dir = manifest_dir.join("../../contributors"); let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| serde_json::from_str::(t).map_err(|e| e.to_string())) .unwrap_or(Value::Null); let people = @@ -26,7 +31,7 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } - pack_catalogs(); + pack_catalogs(&manifest_dir); deflate_egui_fonts(); } @@ -82,9 +87,9 @@ fn catalog_entry(line: &str) -> Option<(&str, &str)> { /// /// Both are deflated. A catalog that can't be written is a build error (`include_bytes!` would /// fail on it anyway, with a less helpful message). -fn pack_catalogs() { +fn pack_catalogs(manifest_dir: &std::path::Path) { use std::io::Write as _; - let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + let src = manifest_dir.join("src/i18n"); let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n"); println!("cargo::rerun-if-changed={}", src.display()); let deflate = |bytes: &[u8]| -> std::io::Result> { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 2277b6e82a2..2ccef1b612f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -20,6 +20,12 @@ mod input; mod prefs; mod screenshot; mod targets; + +impl Drop for PhotocraftApp { + fn drop(&mut self) { + actions::shutdown(self); + } +} mod thumbs; mod view; mod window; @@ -126,6 +132,7 @@ impl PhotocraftApp { automation_input: false, run_depth: 0, paused_playbacks: Default::default(), + actions_runtime: Default::default(), channel_thumbs: None, channel_views: HashMap::new(), type_layout: None, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 1a43ffe77f3..420348b5e9d 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -17,7 +17,13 @@ impl PhotocraftApp { if gate.is_some() { self.session.authorize = gate; } + let discarded = (id == "actions.editor.discard").then(|| params.get("draft").and_then(Value::as_u64)).flatten(); let result = self.run_command(id, params); + if result.is_ok() + && let Some(draft) = discarded + { + actions::discarded(self, draft); + } if gate.is_some() { self.session.authorize = None; } @@ -70,6 +76,9 @@ impl PhotocraftApp { } } actions::suspended_guard(self, id)?; + if let Some(result) = actions::layout_command(self, id) { + return result; + } if let Some(result) = panel_docking::command(self, id, ¶ms) { return result; } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 07ea17b7e81..d34c0f73023 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -82,6 +82,7 @@ impl PhotocraftApp { // shortcuts see Esc). if !screen_picker::tick(self, ctx) { jobs_ui::tick(self, ctx); + crate::actions::tick(self, ctx); #[cfg(not(target_arch = "wasm32"))] crate::dialogs::filter_preview_worker::discard_closed(self); if type_tool::in_ui_pass(ctx) { @@ -308,6 +309,7 @@ impl PhotocraftApp { timeline_ui::windows(self, &ctx); workspace_ui::windows(self, &ctx); palette::show(self, &ctx); + crate::actions::modals(self, &ctx); dialogs::show(self, &ctx); layers_panel_ui::show_dialog(self, &ctx); rasterize_prompt::run_pending(self, &ctx); @@ -347,6 +349,7 @@ impl PhotocraftApp { tool_memory::persist(self, &ctx); // So are View › Extras, Show and Snap To (#3762). view_cmds::persist(self, &ctx); + crate::actions::persist(self, &ctx); // Remember the 0.37 panel sizes so a later 0.36 rollback's newer old-key values win on // the next upgrade (see `panels::migrate_panel_state`). panels::remember_panel_state(&ctx); diff --git a/crates/ui-egui/src/control/inspect.rs b/crates/ui-egui/src/control/inspect.rs index 53df5d373f5..b612ea3393c 100644 --- a/crates/ui-egui/src/control/inspect.rs +++ b/crates/ui-egui/src/control/inspect.rs @@ -34,6 +34,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "scratch": photocraft_raster::spill::stats(), "memory": photocraft_raster::memory::stats(), "tool": app.ui.tool, + "actions": crate::actions::inspect(app), "toolOptions": app.ui.tool_options, "perspectiveCrop": app.ui.perspective_crop, "strokeEditor": app.ui.stroke_editor, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fdbb4cae3d7..fc93f714bc5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -298,6 +298,7 @@ fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: _ if crate::preset_group_prompt::owns(fields) => crate::preset_group_prompt::body(ui, fields), _ if crate::actions::owns_insert_stop(fields) => crate::actions::insert_stop_body(ui, fields), _ if crate::actions::owns_new_set(fields) => crate::actions::new_set_body(ui, fields), + _ if crate::actions::owns_delete(fields) => crate::actions::delete_body(ui, fields), _ if crate::new_layer_ui::owns(fields) => crate::new_layer_ui::body(ui, fields), _ if crate::mask_trash_prompt::owns(fields) => crate::mask_trash_prompt::body(ui, fields), _ if crate::rasterize_prompt::owns(fields) => crate::rasterize_prompt::body(ui, fields), @@ -850,6 +851,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::disk_change_prompt::owns, crate::actions::owns_insert_stop, crate::actions::owns_new_set, + crate::actions::owns_delete, crate::new_layer_ui::owns, crate::new_group_ui::owns, crate::mask_trash_prompt::owns, @@ -1010,6 +1012,7 @@ fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result _ if crate::preset_group_prompt::owns(&d.fields) => crate::preset_group_prompt::confirm(app, &d.fields), _ if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), _ if crate::actions::owns_new_set(&d.fields) => crate::actions::new_set_confirm(app, &d.fields), + _ if crate::actions::owns_delete(&d.fields) => crate::actions::delete_confirm(app, &d.fields), _ if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), _ if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), _ if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), @@ -1066,6 +1069,7 @@ fn confirm_closed(app: &mut PhotocraftApp, d: Dialog) -> Result { pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { let closed = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; crate::actions::stopped(app, &closed.fields); + crate::actions::delete_cancel(app, &closed.fields); if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } diff --git a/crates/ui-egui/src/dialogs/discard_ui.rs b/crates/ui-egui/src/dialogs/discard_ui.rs index 41ca4e4acb0..830f54a39c3 100644 --- a/crates/ui-egui/src/dialogs/discard_ui.rs +++ b/crates/ui-egui/src/dialogs/discard_ui.rs @@ -114,7 +114,7 @@ impl PhotocraftApp { /// Whether closing now would lose work: a document has unsaved changes and closing wasn't /// confirmed. The web build has the browser ask before leaving the page while this holds (#1380). pub fn has_unsaved_work(&self) -> bool { - !self.allow_close && self.session.documents().iter().any(|d| d.is_dirty()) + !self.allow_close && (crate::actions::editor_busy(self) || self.session.documents().iter().any(|d| d.is_dirty())) } } @@ -125,6 +125,9 @@ pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) { if !ctx.input(|i| i.viewport().close_requested()) { return; } + if crate::actions::guard_exit(app, ctx) { + return; + } if !app.allow_close && intercept(app, EXIT, &Value::Null) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0c67a91ec68..157d301f580 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3549,3 +3549,109 @@ curves Smooth تنعيم The path was kept as the Work Path تم الاحتفاظ بالمسار كمسار عمل The path was discarded تم تجاهل المسار Edit Brush… تحرير الفرشاة… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} تم حفظ الإجراء، لكن تعذر حفظ تفضيل التأكيد: {error} + Add a brief description for this action… أضف وصفًا موجزًا لهذا الإجراء… + Allow Tool Recording السماح بتسجيل الأدوات + Answer the open Actions editor question first أجب أولًا عن السؤال المفتوح في محرر الإجراءات + Apply to background تطبيق على الخلفية + Apply to subject تطبيق على الموضوع + Back رجوع + Black and white أبيض وأسود + Cancel and continue editing إلغاء ومتابعة التحرير + Choose color اختيار اللون + Choose icon اختيار الأيقونة + Classic Actions الإجراءات الكلاسيكية + Clear All Actions مسح جميع الإجراءات + Clear search مسح البحث + Click action إجراء نقر + Close the open Actions editor first أغلق أولًا محرر الإجراءات المفتوح + Collapse all طي الكل + Colour lookup tables جداول تحويل الألوان + Confirm or cancel the open action dialog first أكد أو ألغِ أولًا مربع حوار الإجراء المفتوح + Control Ctrl + Default icon الأيقونة الافتراضية + Delete Action حذف الإجراء + Delete action حذف الإجراء + Delete group حذف المجموعة + Delete step حذف الخطوة + Delete the action "{name}"? حذف الإجراء «{name}»؟ + Delete the group "{name}"? حذف المجموعة «{name}»؟ + Discard تجاهل + Download تنزيل + Duplicate Action تكرار الإجراء + Duplicate action تكرار الإجراء + Duplicate group تكرار المجموعة + Duplicate step تكرار الخطوة + Edit Action تحرير الإجراء + Edit action تحرير الإجراء + Edit step settings تحرير إعدادات الخطوة + Enable step تمكين الخطوة + Expand all توسيع الكل + Export Action… تصدير الإجراء… + Finish or cancel action playback and the pending step first أكمل أو ألغِ أولًا تشغيل الإجراء والخطوة المعلقة + Finishing step… جارٍ إنهاء الخطوة… + Generated layer effects تأثيرات طبقة مولدة + Generated styles أنماط مولدة + Grey رمادي + Harmonize layers مواءمة الطبقات + Hue and saturation تدرج اللون والتشبع + Import Actions… استيراد إجراءات… + Import actions or create a new action to get started. استورد إجراءات أو أنشئ إجراءً جديدًا للبدء. + Insert Action إدراج إجراء + Insert Conditional إدراج شرط + Insert Menu Item إدراج عنصر قائمة + Insert Path إدراج مسار + Insert Stop إدراج نقطة توقف + Insert… إدراج… + Keyboard shortcut اختصار لوحة المفاتيح + Lavender لون الخزامى + Layer effects تأثيرات الطبقة + Light blue أزرق فاتح + List size حجم القائمة + Magic wand العصا السحرية + Merge layers دمج الطبقات + Move step down نقل الخطوة لأسفل + Move step up نقل الخطوة لأعلى + New group مجموعة جديدة + No steps yet لا توجد خطوات بعد + Object selection تحديد الكائن + On تشغيل + Play Action تشغيل الإجراء + Play Actions تشغيل الإجراءات + Play action تشغيل الإجراء + Play from selected step تشغيل من الخطوة المحددة + Playback Options… خيارات التشغيل… + Play… تشغيل… + Preview on hover معاينة عند التحويم + Record تسجيل + Recording جارٍ التسجيل + Remove background إزالة الخلفية + Save Action حفظ الإجراء + Search actions البحث في الإجراءات + Select an existing action step to play حدد خطوة إجراء موجودة لتشغيلها + Select subject تحديد الموضوع + Show grid إظهار الشبكة + Show step settings عرض إعدادات الخطوة + Smart removal إزالة ذكية + Sort & View options خيارات الفرز والعرض + Start recording to build your action ابدأ التسجيل لإنشاء إجرائك + Steps الخطوات + Stored action step خطوة إجراء محفوظة + Stored settings إعدادات محفوظة + Teal أزرق مخضر + Text نص + Text colour لون النص + The draft steps changed. Cancel this dialog and reopen the step. تغيرت خطوات المسودة. ألغِ مربع الحوار هذا وأعد فتح الخطوة. + The dragged action changed; drag it again تغير الإجراء المسحوب؛ اسحبه مرة أخرى + The editor response does not belong to this draft استجابة المحرر لا تنتمي إلى هذه المسودة + There are unsaved changes. Do you want to save or discard them? توجد تغييرات غير محفوظة. هل تريد حفظها أم تجاهلها؟ + This editor page is closed صفحة المحرر هذه مغلقة + This step dialog belongs to another editor مربع حوار الخطوة هذا ينتمي إلى محرر آخر + Wait for the current action step to finish انتظر حتى تنتهي خطوة الإجراء الحالية + You haven't created any actions لم تنشئ أي إجراءات بعد + Yours إجراءاتك + +# Actions reserved category caption (10945/10946) + Ungrouped Actions إجراءات غير مجمعة diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8cd95ed30d9..ead8982b09c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4266,3 +4266,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Barva zarážky Shapes can't use patterns yet Tvary zatím nemohou používat vzorky Edit Brush… Upravit štětec… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Akce byla uložena, ale předvolbu potvrzení se nepodařilo uložit: {error} + Add a brief description for this action… Přidejte stručný popis této akce… + Allow Tool Recording Povolit záznam nástrojů + Answer the open Actions editor question first Nejprve odpovězte na otevřenou otázku v editoru akcí + Apply to background Použít na pozadí + Apply to subject Použít na objekt + Black and white Černobílý + Cancel and continue editing Zrušit a pokračovat v úpravách + Choose color Vybrat barvu + Choose icon Vybrat ikonu + Classic Actions Klasické akce + Clear All Actions Vymazat všechny akce + Clear search Vymazat hledání + Click action Akce kliknutí + Close the open Actions editor first Nejprve zavřete otevřený editor akcí + Collapse all Sbalit vše + Colour lookup tables Tabulky pro převod barev + Confirm or cancel the open action dialog first Nejprve potvrďte nebo zrušte otevřený dialog akce + Control Ctrl + Default icon Výchozí ikona + Delete Action Odstranit akci + Delete action Odstranit akci + Delete group Odstranit skupinu + Delete step Odstranit krok + Delete the action "{name}"? Odstranit akci „{name}“? + Delete the group "{name}"? Odstranit skupinu „{name}“? + Discard Zahodit + Download Stáhnout + Duplicate Action Duplikovat akci + Duplicate action Duplikovat akci + Duplicate group Duplikovat skupinu + Duplicate step Duplikovat krok + Edit Action Upravit akci + Edit action Upravit akci + Edit step settings Upravit nastavení kroku + Enable step Povolit krok + Expand all Rozbalit vše + Export Action… Exportovat akci… + Finish or cancel action playback and the pending step first Nejprve dokončete nebo zrušte přehrávání akce a čekající krok + Finishing step… Dokončování kroku… + Generated layer effects Vygenerované efekty vrstvy + Generated styles Vygenerované styly + Grey Šedá + Harmonize layers Sladit vrstvy + Hue and saturation Odstín a sytost + Import Actions… Importovat akce… + Import actions or create a new action to get started. Začněte importem akcí nebo vytvořením nové akce. + Insert Action Vložit akci + Insert Conditional Vložit podmínku + Insert Menu Item Vložit položku nabídky + Insert Path Vložit cestu + Insert Stop Vložit zastavení + Insert… Vložit… + Keyboard shortcut Klávesová zkratka + Lavender Levandulová + Layer effects Efekty vrstvy + Light blue Světle modrá + List size Velikost seznamu + Magic wand Kouzelná hůlka + Merge layers Sloučit vrstvy + Move step down Posunout krok dolů + Move step up Posunout krok nahoru + New group Nová skupina + No steps yet Zatím žádné kroky + Object selection Výběr objektu + Play Action Přehrát akci + Play Actions Přehrát akce + Play action Přehrát akci + Play from selected step Přehrát od vybraného kroku + Playback Options… Možnosti přehrávání… + Play… Přehrát… + Preview on hover Náhled při najetí ukazatele + Record Zaznamenat + Recording Záznam + Remove background Odstranit pozadí + Save Action Uložit akci + Search actions Hledat akce + Select an existing action step to play Vyberte existující krok akce k přehrání + Select subject Vybrat hlavní objekt + Show grid Zobrazit mřížku + Show step settings Zobrazit nastavení kroku + Smart removal Inteligentní odstranění + Sort & View options Možnosti řazení a zobrazení + Start recording to build your action Spusťte záznam a vytvořte svou akci + Steps Kroky + Stored action step Uložený krok akce + Stored settings Uložená nastavení + Teal Modrozelená + Text Text + Text colour Barva textu + The draft steps changed. Cancel this dialog and reopen the step. Kroky konceptu se změnily. Zrušte tento dialog a krok znovu otevřete. + The dragged action changed; drag it again Přetahovaná akce se změnila; přetáhněte ji znovu + The editor response does not belong to this draft Odpověď editoru nepatří k tomuto konceptu + There are unsaved changes. Do you want to save or discard them? Máte neuložené změny. Chcete je uložit, nebo zahodit? + This editor page is closed Tato stránka editoru je zavřená + This step dialog belongs to another editor Tento dialog kroku patří jinému editoru + Wait for the current action step to finish Počkejte na dokončení aktuálního kroku akce + You haven't created any actions Zatím jste nevytvořili žádné akce + Yours Vaše + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Neseskupené akce diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 46e71ed2cf7..119ffa7bd99 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4773,3 +4773,107 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet The path was discarded Stien blev kasseret The path was kept as the Work Path Stien blev bevaret som arbejdssti Edit Brush… Rediger pensel… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Handlingen blev gemt, men bekræftelsesindstillingen kunne ikke gemmes: {error} + Add a brief description for this action… Tilføj en kort beskrivelse af denne handling… + Allow Tool Recording Tillad optagelse af værktøjer + Answer the open Actions editor question first Besvar først det åbne spørgsmål i handlingseditoren + Apply to background Anvend på baggrunden + Apply to subject Anvend på motivet + Black and white Sort-hvid + Cancel and continue editing Annuller og fortsæt redigeringen + Choose color Vælg farve + Choose icon Vælg ikon + Classic Actions Klassiske handlinger + Clear All Actions Ryd alle handlinger + Clear search Ryd søgningen + Click action Klikhandling + Close the open Actions editor first Luk først den åbne handlingseditor + Collapse all Fold alle sammen + Colour lookup tables Farveopslagstabeller + Confirm or cancel the open action dialog first Bekræft eller annuller først den åbne handlingsdialog + Control Ctrl + Default icon Standardikon + Delete Action Slet handling + Delete action Slet handling + Delete group Slet gruppe + Delete step Slet trin + Delete the action "{name}"? Slet handlingen »{name}«? + Delete the group "{name}"? Slet gruppen »{name}«? + Discard Kassér + Download Hent + Duplicate Action Dupliker handling + Duplicate action Dupliker handling + Duplicate group Dupliker gruppe + Duplicate step Dupliker trin + Edit Action Rediger handling + Edit action Rediger handling + Edit step settings Rediger trinindstillinger + Enable step Aktivér trin + Expand all Udvid alle + Export Action… Eksportér handling… + Finish or cancel action playback and the pending step first Afslut eller annuller først afspilningen af handlingen og det afventende trin + Finishing step… Afslutter trin… + Generated layer effects Genererede lageffekter + Generated styles Genererede stilarter + Grey Grå + Harmonize layers Harmoniser lag + Hue and saturation Farvetone og mætning + Import Actions… Importér handlinger… + Import actions or create a new action to get started. Importér handlinger, eller opret en ny handling for at komme i gang. + Insert Action Indsæt handling + Insert Conditional Indsæt betingelse + Insert Menu Item Indsæt menupunkt + Insert Path Indsæt kurve + Insert Stop Indsæt stop + Insert… Indsæt… + Keyboard shortcut Tastaturgenvej + Lavender Lavendel + Layer effects Lageffekter + Light blue Lyseblå + List size Listestørrelse + Magic wand Tryllestav + Merge layers Flet lag + Move step down Flyt trin ned + Move step up Flyt trin op + New group Ny gruppe + No steps yet Ingen trin endnu + Object selection Objektmarkering + Play Action Afspil handling + Play Actions Afspil handlinger + Play action Afspil handling + Play from selected step Afspil fra det valgte trin + Playback Options… Afspilningsindstillinger… + Play… Afspil… + Preview on hover Forhåndsvis ved at holde markøren over + Record Optag + Recording Optager + Remove background Fjern baggrund + Save Action Gem handling + Search actions Søg efter handlinger + Select an existing action step to play Vælg et eksisterende handlingstrin, der skal afspilles + Select subject Markér motiv + Show grid Vis gitter + Show step settings Vis trinindstillinger + Smart removal Intelligent fjernelse + Sort & View options Sorterings- og visningsindstillinger + Start recording to build your action Start optagelsen for at oprette din handling + Steps Trin + Stored action step Gemt handlingstrin + Stored settings Gemte indstillinger + Teal Blågrøn + Text Tekst + Text colour Tekstfarve + The draft steps changed. Cancel this dialog and reopen the step. Kladdens trin er ændret. Annuller denne dialog, og åbn trinnet igen. + The dragged action changed; drag it again Den trukne handling er ændret; træk den igen + The editor response does not belong to this draft Editorens svar hører ikke til denne kladde + There are unsaved changes. Do you want to save or discard them? Der er ændringer, som ikke er gemt. Vil du gemme eller kassere dem? + This editor page is closed Denne editorside er lukket + This step dialog belongs to another editor Denne trindialog hører til en anden editor + Wait for the current action step to finish Vent, til det aktuelle handlingstrin er færdigt + You haven't created any actions Du har ikke oprettet nogen handlinger + Yours Dine + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Ikke-grupperede handlinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 45fb326bea4..2d55876f654 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3551,3 +3551,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color Volltonfarbe Tint Color Tönungsfarbe Edit Brush… Pinsel bearbeiten… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Die Aktion wurde gespeichert, die Bestätigungseinstellung konnte jedoch nicht gespeichert werden: {error} + Add a brief description for this action… Fügen Sie eine kurze Beschreibung dieser Aktion hinzu… + Allow Tool Recording Werkzeugaufzeichnung zulassen + Answer the open Actions editor question first Beantworten Sie zuerst die offene Frage im Aktionseditor + Apply to background Auf Hintergrund anwenden + Apply to subject Auf Motiv anwenden + Back Zurück + Black and white Schwarzweiß + Cancel and continue editing Abbrechen und weiter bearbeiten + Choose color Farbe wählen + Choose icon Symbol wählen + Classic Actions Klassische Aktionen + Clear All Actions Alle Aktionen löschen + Clear search Suche löschen + Click action Klickaktion + Close the open Actions editor first Schließen Sie zuerst den geöffneten Aktionseditor + Collapse all Alle einklappen + Colour lookup tables Farbzuordnungstabellen + Confirm or cancel the open action dialog first Bestätigen Sie zuerst den geöffneten Aktionsdialog oder brechen Sie ihn ab + Control Ctrl + Default icon Standardsymbol + Delete Action Aktion löschen + Delete action Aktion löschen + Delete group Gruppe löschen + Delete step Schritt löschen + Delete the action "{name}"? Aktion „{name}“ löschen? + Delete the group "{name}"? Gruppe „{name}“ löschen? + Discard Verwerfen + Download Herunterladen + Duplicate Action Aktion duplizieren + Duplicate action Aktion duplizieren + Duplicate group Gruppe duplizieren + Duplicate step Schritt duplizieren + Edit Action Aktion bearbeiten + Edit action Aktion bearbeiten + Edit step settings Schritteinstellungen bearbeiten + Enable step Schritt aktivieren + Expand all Alle ausklappen + Export Action… Aktion exportieren… + Finish or cancel action playback and the pending step first Schließen Sie zuerst die Aktionswiedergabe und den ausstehenden Schritt ab oder brechen Sie beides ab + Finishing step… Schritt wird abgeschlossen… + Generated layer effects Erzeugte Ebeneneffekte + Generated styles Erzeugte Stile + Grey Grau + Harmonize layers Ebenen harmonisieren + Hue and saturation Farbton und Sättigung + Import Actions… Aktionen importieren… + Import actions or create a new action to get started. Importieren Sie Aktionen oder erstellen Sie eine neue Aktion, um zu beginnen. + Insert Action Aktion einfügen + Insert Conditional Bedingung einfügen + Insert Menu Item Menüeintrag einfügen + Insert Path Pfad einfügen + Insert Stop Stopp einfügen + Insert… Einfügen… + Keyboard shortcut Tastenkombination + Lavender Lavendel + Layer effects Ebeneneffekte + Light blue Hellblau + List size Listengröße + Magic wand Zauberstab + Merge layers Ebenen zusammenfügen + Move step down Schritt nach unten verschieben + Move step up Schritt nach oben verschieben + New group Neue Gruppe + No steps yet Noch keine Schritte + Object selection Objektauswahl + On Ein + Play Action Aktion wiedergeben + Play Actions Aktionen wiedergeben + Play action Aktion wiedergeben + Play from selected step Ab ausgewähltem Schritt wiedergeben + Playback Options… Wiedergabeoptionen… + Play… Wiedergeben… + Preview on hover Vorschau beim Darüberfahren + Record Aufzeichnen + Recording Aufzeichnung + Remove background Hintergrund entfernen + Save Action Aktion speichern + Search actions Aktionen suchen + Select an existing action step to play Wählen Sie einen vorhandenen Aktionsschritt zur Wiedergabe + Select subject Motiv auswählen + Show grid Raster einblenden + Show step settings Schritteinstellungen anzeigen + Smart removal Intelligentes Entfernen + Sort & View options Sortier- und Ansichtsoptionen + Start recording to build your action Starten Sie die Aufzeichnung, um Ihre Aktion zu erstellen + Steps Schritte + Stored action step Gespeicherter Aktionsschritt + Stored settings Gespeicherte Einstellungen + Teal Blaugrün + Text Text + Text colour Textfarbe + The draft steps changed. Cancel this dialog and reopen the step. Die Entwurfsschritte wurden geändert. Brechen Sie diesen Dialog ab und öffnen Sie den Schritt erneut. + The dragged action changed; drag it again Die gezogene Aktion wurde geändert; ziehen Sie sie erneut + The editor response does not belong to this draft Die Antwort des Editors gehört nicht zu diesem Entwurf + There are unsaved changes. Do you want to save or discard them? Es gibt ungespeicherte Änderungen. Möchten Sie sie speichern oder verwerfen? + This editor page is closed Diese Editorseite ist geschlossen + This step dialog belongs to another editor Dieser Schrittdialog gehört zu einem anderen Editor + Wait for the current action step to finish Warten Sie, bis der aktuelle Aktionsschritt abgeschlossen ist + You haven't created any actions Sie haben noch keine Aktionen erstellt + Yours Eigene + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Nicht gruppierte Aktionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8f20dd6705e..f686f2b754d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4220,3 +4220,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Χρώμα στάσης Shapes can't use patterns yet Τα σχήματα δεν μπορούν ακόμη να χρησιμοποιούν μοτίβα Edit Brush… Επεξεργασία πινέλου… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Η ενέργεια αποθηκεύτηκε, αλλά δεν ήταν δυνατή η αποθήκευση της προτίμησης επιβεβαίωσης: {error} + Add a brief description for this action… Προσθέστε μια σύντομη περιγραφή για αυτή την ενέργεια… + Allow Tool Recording Να επιτρέπεται η καταγραφή εργαλείων + Answer the open Actions editor question first Απαντήστε πρώτα στην ανοιχτή ερώτηση στον επεξεργαστή ενεργειών + Apply to background Εφαρμογή στο φόντο + Apply to subject Εφαρμογή στο θέμα + Black and white Ασπρόμαυρο + Cancel and continue editing Ακύρωση και συνέχεια επεξεργασίας + Choose color Επιλογή χρώματος + Choose icon Επιλογή εικονιδίου + Classic Actions Κλασικές ενέργειες + Clear All Actions Εκκαθάριση όλων των ενεργειών + Clear search Εκκαθάριση αναζήτησης + Click action Ενέργεια κλικ + Close the open Actions editor first Κλείστε πρώτα τον ανοιχτό επεξεργαστή ενεργειών + Collapse all Σύμπτυξη όλων + Colour lookup tables Πίνακες αντιστοίχισης χρωμάτων + Confirm or cancel the open action dialog first Επιβεβαιώστε ή ακυρώστε πρώτα τον ανοιχτό διάλογο ενέργειας + Control Ctrl + Default icon Προεπιλεγμένο εικονίδιο + Delete Action Διαγραφή ενέργειας + Delete action Διαγραφή ενέργειας + Delete group Διαγραφή ομάδας + Delete step Διαγραφή βήματος + Delete the action "{name}"? Διαγραφή της ενέργειας «{name}»; + Delete the group "{name}"? Διαγραφή της ομάδας «{name}»; + Discard Απόρριψη + Download Λήψη + Duplicate Action Διπλασιασμός ενέργειας + Duplicate action Διπλασιασμός ενέργειας + Duplicate group Διπλασιασμός ομάδας + Duplicate step Διπλασιασμός βήματος + Edit Action Επεξεργασία ενέργειας + Edit action Επεξεργασία ενέργειας + Edit step settings Επεξεργασία ρυθμίσεων βήματος + Enable step Ενεργοποίηση βήματος + Expand all Ανάπτυξη όλων + Export Action… Εξαγωγή ενέργειας… + Finish or cancel action playback and the pending step first Ολοκληρώστε ή ακυρώστε πρώτα την αναπαραγωγή της ενέργειας και το εκκρεμές βήμα + Finishing step… Ολοκλήρωση βήματος… + Generated layer effects Παραγόμενα εφέ επιπέδου + Generated styles Παραγόμενα στυλ + Grey Γκρι + Harmonize layers Εναρμόνιση επιπέδων + Hue and saturation Απόχρωση και κορεσμός + Import Actions… Εισαγωγή ενεργειών… + Import actions or create a new action to get started. Εισαγάγετε ενέργειες ή δημιουργήστε μια νέα ενέργεια για να ξεκινήσετε. + Insert Action Εισαγωγή ενέργειας + Insert Conditional Εισαγωγή συνθήκης + Insert Menu Item Εισαγωγή στοιχείου μενού + Insert Path Εισαγωγή διαδρομής + Insert Stop Εισαγωγή διακοπής + Insert… Εισαγωγή… + Keyboard shortcut Συντόμευση πληκτρολογίου + Lavender Λεβάντα + Layer effects Εφέ επιπέδου + Light blue Ανοιχτό μπλε + List size Μέγεθος λίστας + Magic wand Μαγικό ραβδί + Merge layers Συγχώνευση επιπέδων + Move step down Μετακίνηση βήματος κάτω + Move step up Μετακίνηση βήματος πάνω + New group Νέα ομάδα + No steps yet Δεν υπάρχουν βήματα ακόμη + Object selection Επιλογή αντικειμένου + Play Action Αναπαραγωγή ενέργειας + Play Actions Αναπαραγωγή ενεργειών + Play action Αναπαραγωγή ενέργειας + Play from selected step Αναπαραγωγή από το επιλεγμένο βήμα + Playback Options… Επιλογές αναπαραγωγής… + Play… Αναπαραγωγή… + Preview on hover Προεπισκόπηση κατά την αιώρηση + Record Καταγραφή + Recording Γίνεται καταγραφή + Remove background Αφαίρεση φόντου + Save Action Αποθήκευση ενέργειας + Search actions Αναζήτηση ενεργειών + Select an existing action step to play Επιλέξτε ένα υπάρχον βήμα ενέργειας για αναπαραγωγή + Select subject Επιλογή κύριου θέματος + Show grid Εμφάνιση πλέγματος + Show step settings Εμφάνιση ρυθμίσεων βήματος + Smart removal Έξυπνη αφαίρεση + Sort & View options Επιλογές ταξινόμησης και προβολής + Start recording to build your action Ξεκινήστε την καταγραφή για να δημιουργήσετε την ενέργειά σας + Steps Βήματα + Stored action step Αποθηκευμένο βήμα ενέργειας + Stored settings Αποθηκευμένες ρυθμίσεις + Teal Πετρόλ + Text Κείμενο + Text colour Χρώμα κειμένου + The draft steps changed. Cancel this dialog and reopen the step. Τα βήματα του προσχεδίου άλλαξαν. Ακυρώστε αυτόν τον διάλογο και ανοίξτε ξανά το βήμα. + The dragged action changed; drag it again Η ενέργεια που σύρατε άλλαξε· σύρετέ την ξανά + The editor response does not belong to this draft Η απόκριση του επεξεργαστή δεν ανήκει σε αυτό το προσχέδιο + There are unsaved changes. Do you want to save or discard them? Υπάρχουν μη αποθηκευμένες αλλαγές. Θέλετε να τις αποθηκεύσετε ή να τις απορρίψετε; + This editor page is closed Αυτή η σελίδα του επεξεργαστή είναι κλειστή + This step dialog belongs to another editor Αυτός ο διάλογος βήματος ανήκει σε άλλον επεξεργαστή + Wait for the current action step to finish Περιμένετε να ολοκληρωθεί το τρέχον βήμα ενέργειας + You haven't created any actions Δεν έχετε δημιουργήσει καμία ενέργεια + Yours Δικές σας + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Μη ομαδοποιημένες ενέργειες diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6b365ce2be0..7dc499accd0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4432,3 +4432,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Color de la parada Shapes can't use patterns yet Las formas aún no pueden usar motivos Edit Brush… Editar pincel… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} La acción se guardó, pero no se pudo guardar la preferencia de confirmación: {error} + Add a brief description for this action… Añade una breve descripción de esta acción… + Allow Tool Recording Permitir la grabación de herramientas + Answer the open Actions editor question first Responde primero a la pregunta abierta en el editor de acciones + Apply to background Aplicar al fondo + Apply to subject Aplicar al sujeto + Black and white Blanco y negro + Cancel and continue editing Cancelar y seguir editando + Choose color Elegir color + Choose icon Elegir icono + Classic Actions Acciones clásicas + Clear All Actions Borrar todas las acciones + Clear search Borrar búsqueda + Click action Acción de clic + Close the open Actions editor first Cierra primero el editor de acciones abierto + Collapse all Contraer todo + Colour lookup tables Tablas de consulta de color + Confirm or cancel the open action dialog first Confirma o cancela primero el cuadro de diálogo de acción abierto + Control Ctrl + Default icon Icono predeterminado + Delete Action Eliminar acción + Delete action Eliminar acción + Delete group Eliminar grupo + Delete step Eliminar paso + Delete the action "{name}"? ¿Eliminar la acción «{name}»? + Delete the group "{name}"? ¿Eliminar el grupo «{name}»? + Discard Descartar + Download Descargar + Duplicate Action Duplicar acción + Duplicate action Duplicar acción + Duplicate group Duplicar grupo + Duplicate step Duplicar paso + Edit Action Editar acción + Edit action Editar acción + Edit step settings Editar ajustes del paso + Enable step Activar paso + Expand all Expandir todo + Export Action… Exportar acción… + Finish or cancel action playback and the pending step first Finaliza o cancela primero la reproducción de la acción y el paso pendiente + Finishing step… Finalizando paso… + Generated layer effects Efectos de capa generados + Generated styles Estilos generados + Grey Gris + Harmonize layers Armonizar capas + Hue and saturation Tono y saturación + Import Actions… Importar acciones… + Import actions or create a new action to get started. Importa acciones o crea una nueva para empezar. + Insert Action Insertar acción + Insert Conditional Insertar condición + Insert Menu Item Insertar elemento de menú + Insert Path Insertar trazado + Insert Stop Insertar detención + Insert… Insertar… + Keyboard shortcut Atajo de teclado + Lavender Lavanda + Layer effects Efectos de capa + Light blue Azul claro + List size Tamaño de lista + Magic wand Varita mágica + Merge layers Combinar capas + Move step down Mover paso abajo + Move step up Mover paso arriba + New group Nuevo grupo + No steps yet Aún no hay pasos + Object selection Selección de objetos + Play Action Reproducir acción + Play Actions Reproducir acciones + Play action Reproducir acción + Play from selected step Reproducir desde el paso seleccionado + Playback Options… Opciones de reproducción… + Play… Reproducir… + Preview on hover Vista previa al pasar el cursor + Record Grabar + Recording Grabando + Remove background Quitar el fondo + Save Action Guardar acción + Search actions Buscar acciones + Select an existing action step to play Selecciona un paso de acción existente para reproducirlo + Select subject Seleccionar sujeto + Show grid Mostrar cuadrícula + Show step settings Mostrar ajustes del paso + Smart removal Eliminación inteligente + Sort & View options Opciones de orden y vista + Start recording to build your action Inicia la grabación para crear tu acción + Steps Pasos + Stored action step Paso de acción guardado + Stored settings Ajustes guardados + Teal Verde azulado + Text Texto + Text colour Color del texto + The draft steps changed. Cancel this dialog and reopen the step. Los pasos del borrador han cambiado. Cancela este cuadro de diálogo y vuelve a abrir el paso. + The dragged action changed; drag it again La acción arrastrada ha cambiado; vuelve a arrastrarla + The editor response does not belong to this draft La respuesta del editor no pertenece a este borrador + There are unsaved changes. Do you want to save or discard them? Hay cambios sin guardar. ¿Quieres guardarlos o descartarlos? + This editor page is closed Esta página del editor está cerrada + This step dialog belongs to another editor Este cuadro de diálogo de paso pertenece a otro editor + Wait for the current action step to finish Espera a que termine el paso de acción actual + You haven't created any actions No has creado ninguna acción + Yours Tus acciones + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Acciones sin agrupar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b08a224b274..3cd4e9a3c2c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4774,3 +4774,107 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen The path was discarded Polku hylättiin The path was kept as the Work Path Polku säilytettiin työpolkuna Edit Brush… Muokkaa sivellintä… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Toiminto tallennettiin, mutta vahvistusasetusta ei voitu tallentaa: {error} + Add a brief description for this action… Lisää lyhyt kuvaus tästä toiminnosta… + Allow Tool Recording Salli työkalujen tallennus + Answer the open Actions editor question first Vastaa ensin toimintomuokkaimen avoimeen kysymykseen + Apply to background Käytä taustaan + Apply to subject Käytä kohteeseen + Black and white Mustavalkoinen + Cancel and continue editing Peruuta ja jatka muokkaamista + Choose color Valitse väri + Choose icon Valitse kuvake + Classic Actions Perinteiset toiminnot + Clear All Actions Tyhjennä kaikki toiminnot + Clear search Tyhjennä haku + Click action Napsautustoiminto + Close the open Actions editor first Sulje ensin avoin toimintomuokkain + Collapse all Supista kaikki + Colour lookup tables Värien hakutaulukot + Confirm or cancel the open action dialog first Vahvista tai peruuta ensin avoin toiminnon valintaikkuna + Control Ctrl + Default icon Oletuskuvake + Delete Action Poista toiminto + Delete action Poista toiminto + Delete group Poista ryhmä + Delete step Poista vaihe + Delete the action "{name}"? Poistetaanko toiminto ”{name}”? + Delete the group "{name}"? Poistetaanko ryhmä ”{name}”? + Discard Hylkää + Download Lataa + Duplicate Action Monista toiminto + Duplicate action Monista toiminto + Duplicate group Monista ryhmä + Duplicate step Monista vaihe + Edit Action Muokkaa toimintoa + Edit action Muokkaa toimintoa + Edit step settings Muokkaa vaiheen asetuksia + Enable step Ota vaihe käyttöön + Expand all Laajenna kaikki + Export Action… Vie toiminto… + Finish or cancel action playback and the pending step first Viimeistele tai peruuta ensin toiminnon toisto ja odottava vaihe + Finishing step… Viimeistellään vaihetta… + Generated layer effects Luodut tasotehosteet + Generated styles Luodut tyylit + Grey Harmaa + Harmonize layers Yhtenäistä tasot + Hue and saturation Sävy ja kylläisyys + Import Actions… Tuo toimintoja… + Import actions or create a new action to get started. Aloita tuomalla toimintoja tai luomalla uusi toiminto. + Insert Action Lisää toiminto + Insert Conditional Lisää ehto + Insert Menu Item Lisää valikkokohta + Insert Path Lisää polku + Insert Stop Lisää pysäytys + Insert… Lisää… + Keyboard shortcut Pikanäppäin + Lavender Laventeli + Layer effects Tasotehosteet + Light blue Vaaleansininen + List size Luettelon koko + Magic wand Taikasauva + Merge layers Yhdistä tasot + Move step down Siirrä vaihe alas + Move step up Siirrä vaihe ylös + New group Uusi ryhmä + No steps yet Ei vielä vaiheita + Object selection Objektin valinta + Play Action Toista toiminto + Play Actions Toista toiminnot + Play action Toista toiminto + Play from selected step Toista valitusta vaiheesta + Playback Options… Toistoasetukset… + Play… Toista… + Preview on hover Esikatsele osoitettaessa + Record Tallenna suoritus + Recording Suorituksen tallennus + Remove background Poista tausta + Save Action Tallenna toiminto + Search actions Hae toimintoja + Select an existing action step to play Valitse olemassa oleva toimintovaihe toistettavaksi + Select subject Valitse kohde + Show grid Näytä ruudukko + Show step settings Näytä vaiheen asetukset + Smart removal Älykäs poisto + Sort & View options Lajittelu- ja näkymäasetukset + Start recording to build your action Aloita suorituksen tallennus luodaksesi toiminnon + Steps Vaiheet + Stored action step Tallennettu toimintovaihe + Stored settings Tallennetut asetukset + Teal Sinivihreä + Text Teksti + Text colour Tekstin väri + The draft steps changed. Cancel this dialog and reopen the step. Luonnoksen vaiheet ovat muuttuneet. Peruuta tämä valintaikkuna ja avaa vaihe uudelleen. + The dragged action changed; drag it again Vedetty toiminto on muuttunut; vedä se uudelleen + The editor response does not belong to this draft Muokkaimen vastaus ei kuulu tähän luonnokseen + There are unsaved changes. Do you want to save or discard them? Muutoksia on tallentamatta. Haluatko tallentaa vai hylätä ne? + This editor page is closed Tämä muokkaimen sivu on suljettu + This step dialog belongs to another editor Tämä vaiheen valintaikkuna kuuluu toiseen muokkaimeen + Wait for the current action step to finish Odota nykyisen toimintovaiheen valmistumista + You haven't created any actions Et ole vielä luonut toimintoja + Yours Omat + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Ryhmittelemättömät toiminnot diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 89bdec45d40..46dd6c3fcb7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4556,3 +4556,107 @@ shape Appearance Apparence Stop Color Couleur du point Shapes can't use patterns yet Les formes ne peuvent pas encore utiliser de motifs Edit Brush… Modifier le pinceau… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} L’action a été enregistrée, mais la préférence de confirmation n’a pas pu être enregistrée : {error} + Add a brief description for this action… Ajoutez une brève description de cette action… + Allow Tool Recording Autoriser l’enregistrement des outils + Answer the open Actions editor question first Répondez d’abord à la question ouverte dans l’éditeur d’actions + Apply to background Appliquer à l’arrière-plan + Apply to subject Appliquer au sujet + Black and white Noir et blanc + Cancel and continue editing Annuler et continuer la modification + Choose color Choisir une couleur + Choose icon Choisir une icône + Classic Actions Actions classiques + Clear All Actions Effacer toutes les actions + Clear search Effacer la recherche + Click action Action de clic + Close the open Actions editor first Fermez d’abord l’éditeur d’actions ouvert + Collapse all Tout réduire + Colour lookup tables Tables de correspondance des couleurs + Confirm or cancel the open action dialog first Confirmez ou annulez d’abord la boîte de dialogue d’action ouverte + Control Ctrl + Default icon Icône par défaut + Delete Action Supprimer l’action + Delete action Supprimer l’action + Delete group Supprimer le groupe + Delete step Supprimer l’étape + Delete the action "{name}"? Supprimer l’action « {name} » ? + Delete the group "{name}"? Supprimer le groupe « {name} » ? + Discard Abandonner + Download Télécharger + Duplicate Action Dupliquer l’action + Duplicate action Dupliquer l’action + Duplicate group Dupliquer le groupe + Duplicate step Dupliquer l’étape + Edit Action Modifier l’action + Edit action Modifier l’action + Edit step settings Modifier les paramètres de l’étape + Enable step Activer l’étape + Expand all Tout développer + Export Action… Exporter l’action… + Finish or cancel action playback and the pending step first Terminez ou annulez d’abord la lecture de l’action et l’étape en attente + Finishing step… Fin de l’étape… + Generated layer effects Effets de calque générés + Generated styles Styles générés + Grey Gris + Harmonize layers Harmoniser les calques + Hue and saturation Teinte et saturation + Import Actions… Importer des actions… + Import actions or create a new action to get started. Importez des actions ou créez-en une pour commencer. + Insert Action Insérer une action + Insert Conditional Insérer une condition + Insert Menu Item Insérer un élément de menu + Insert Path Insérer un tracé + Insert Stop Insérer un arrêt + Insert… Insérer… + Keyboard shortcut Raccourci clavier + Lavender Lavande + Layer effects Effets de calque + Light blue Bleu clair + List size Taille de la liste + Magic wand Baguette magique + Merge layers Fusionner les calques + Move step down Descendre l’étape + Move step up Monter l’étape + New group Nouveau groupe + No steps yet Aucune étape pour le moment + Object selection Sélection d’objet + Play Action Lire l’action + Play Actions Lire les actions + Play action Lire l’action + Play from selected step Lire à partir de l’étape sélectionnée + Playback Options… Options de lecture… + Play… Lecture… + Preview on hover Aperçu au survol + Record Enregistrer + Recording Enregistrement + Remove background Supprimer l'arrière-plan + Save Action Enregistrer l’action + Search actions Rechercher des actions + Select an existing action step to play Sélectionnez une étape d’action existante à lire + Select subject Sélectionner le sujet + Show grid Afficher la grille + Show step settings Afficher les paramètres de l’étape + Smart removal Suppression intelligente + Sort & View options Options de tri et d’affichage + Start recording to build your action Lancez l’enregistrement pour créer votre action + Steps Étapes + Stored action step Étape d’action enregistrée + Stored settings Paramètres enregistrés + Teal Bleu sarcelle + Text Texte + Text colour Couleur du texte + The draft steps changed. Cancel this dialog and reopen the step. Les étapes du brouillon ont changé. Annulez cette boîte de dialogue et rouvrez l’étape. + The dragged action changed; drag it again L’action déplacée a changé ; faites-la glisser à nouveau + The editor response does not belong to this draft La réponse de l’éditeur n’appartient pas à ce brouillon + There are unsaved changes. Do you want to save or discard them? Des modifications ne sont pas enregistrées. Voulez-vous les enregistrer ou les abandonner ? + This editor page is closed Cette page de l’éditeur est fermée + This step dialog belongs to another editor Cette boîte de dialogue d’étape appartient à un autre éditeur + Wait for the current action step to finish Attendez la fin de l’étape d’action en cours + You haven't created any actions Vous n’avez créé aucune action + Yours Vos actions + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Actions non groupées diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c5a61baed85..a521c5224af 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4440,3 +4440,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Színpont színe Shapes can't use patterns yet Az alakzatok még nem használhatnak mintákat Edit Brush… Ecset szerkesztése… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} A művelet mentve, de a megerősítési beállítást nem sikerült menteni: {error} + Add a brief description for this action… Adja meg a művelet rövid leírását… + Allow Tool Recording Eszközrögzítés engedélyezése + Answer the open Actions editor question first Először válaszoljon a műveletszerkesztő nyitott kérdésére + Apply to background Alkalmazás a háttérre + Apply to subject Alkalmazás a témára + Black and white Fekete-fehér + Cancel and continue editing Mégse és a szerkesztés folytatása + Choose color Szín kiválasztása + Choose icon Ikon kiválasztása + Classic Actions Klasszikus műveletsorok + Clear All Actions Összes művelet törlése + Clear search Keresés törlése + Click action Kattintási művelet + Close the open Actions editor first Először zárja be a nyitott műveletszerkesztőt + Collapse all Összes összecsukása + Colour lookup tables Színkeresési táblázatok + Confirm or cancel the open action dialog first Először erősítse meg vagy szakítsa meg a nyitott műveletpárbeszédet + Control Ctrl + Default icon Alapértelmezett ikon + Delete Action Művelet törlése + Delete action Művelet törlése + Delete group Csoport törlése + Delete step Lépés törlése + Delete the action "{name}"? Törli a(z) „{name}” műveletet? + Delete the group "{name}"? Törli a(z) „{name}” csoportot? + Discard Elvetés + Download Letöltés + Duplicate Action Művelet megkettőzése + Duplicate action Művelet megkettőzése + Duplicate group Csoport megkettőzése + Duplicate step Lépés megkettőzése + Edit Action Művelet szerkesztése + Edit action Művelet szerkesztése + Edit step settings Lépésbeállítások szerkesztése + Enable step Lépés engedélyezése + Expand all Összes kibontása + Export Action… Művelet exportálása… + Finish or cancel action playback and the pending step first Először fejezze be vagy szakítsa meg a művelet lejátszását és a függőben lévő lépést + Finishing step… Lépés befejezése… + Generated layer effects Létrehozott réteghatások + Generated styles Létrehozott stílusok + Grey Szürke + Harmonize layers Rétegek összehangolása + Hue and saturation Színezet és telítettség + Import Actions… Műveletek importálása… + Import actions or create a new action to get started. A kezdéshez importáljon műveleteket, vagy hozzon létre új műveletet. + Insert Action Művelet beszúrása + Insert Conditional Feltétel beszúrása + Insert Menu Item Menüelem beszúrása + Insert Path Görbe beszúrása + Insert Stop Leállítás beszúrása + Insert… Beszúrás… + Keyboard shortcut Billentyűparancs + Lavender Levendula + Layer effects Réteghatások + Light blue Világoskék + List size Lista mérete + Magic wand Varázspálca + Merge layers Rétegek egyesítése + Move step down Lépés mozgatása lefelé + Move step up Lépés mozgatása felfelé + New group Új csoport + No steps yet Még nincsenek lépések + Object selection Objektumkijelölés + Play Action Művelet lejátszása + Play Actions Műveletek lejátszása + Play action Művelet lejátszása + Play from selected step Lejátszás a kijelölt lépéstől + Playback Options… Lejátszási beállítások… + Play… Lejátszás… + Preview on hover Előnézet rámutatáskor + Record Rögzítés + Recording Rögzítés folyamatban + Remove background Háttér eltávolítása + Save Action Művelet mentése + Search actions Műveletek keresése + Select an existing action step to play Válasszon egy meglévő műveletlépést a lejátszáshoz + Select subject Fő téma kijelölése + Show grid Rács megjelenítése + Show step settings Lépésbeállítások megjelenítése + Smart removal Intelligens eltávolítás + Sort & View options Rendezési és nézetbeállítások + Start recording to build your action Indítsa el a rögzítést a művelet létrehozásához + Steps Lépések + Stored action step Tárolt műveletlépés + Stored settings Tárolt beállítások + Teal Kékeszöld + Text Szöveg + Text colour Szövegszín + The draft steps changed. Cancel this dialog and reopen the step. A piszkozat lépései megváltoztak. Szakítsa meg ezt a párbeszédet, és nyissa meg újra a lépést. + The dragged action changed; drag it again A húzott művelet megváltozott; húzza újra + The editor response does not belong to this draft A szerkesztő válasza nem ehhez a piszkozathoz tartozik + There are unsaved changes. Do you want to save or discard them? Nem mentett módosítások vannak. Menti vagy elveti őket? + This editor page is closed Ez a szerkesztőoldal be van zárva + This step dialog belongs to another editor Ez a lépéspárbeszéd egy másik szerkesztőhöz tartozik + Wait for the current action step to finish Várja meg az aktuális műveletlépés befejezését + You haven't created any actions Még nem hozott létre műveletet + Yours Saját + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Nem csoportosított műveletsorok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e5058af2c9a..8bd7b02f2e3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3543,3 +3543,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color Warna Solid Tint Color Warna Tint Edit Brush… Edit Kuas… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Action disimpan, tetapi preferensi konfirmasi tidak dapat disimpan: {error} + Add a brief description for this action… Tambahkan deskripsi singkat untuk action ini… + Allow Tool Recording Izinkan perekaman alat + Answer the open Actions editor question first Jawab dahulu pertanyaan yang terbuka di editor action + Apply to background Terapkan ke latar belakang + Apply to subject Terapkan ke subjek + Back Kembali + Black and white Hitam putih + Cancel and continue editing Batal dan lanjutkan mengedit + Choose color Pilih warna + Choose icon Pilih ikon + Classic Actions Action Klasik + Clear All Actions Hapus Semua Action + Clear search Hapus pencarian + Click action Action klik + Close the open Actions editor first Tutup dahulu editor action yang terbuka + Collapse all Ciutkan semua + Colour lookup tables Tabel pencarian warna + Confirm or cancel the open action dialog first Konfirmasikan atau batalkan dahulu dialog action yang terbuka + Control Ctrl + Default icon Ikon bawaan + Delete Action Hapus action + Delete action Hapus action + Delete group Hapus Grup + Delete step Hapus langkah + Delete the action "{name}"? Hapus action “{name}”? + Delete the group "{name}"? Hapus grup “{name}”? + Discard Buang + Download Unduh + Duplicate Action Duplikasikan action + Duplicate action Duplikasikan action + Duplicate group Duplikasikan grup + Duplicate step Duplikasikan langkah + Edit Action Sunting action + Edit action Sunting action + Edit step settings Sunting pengaturan langkah + Enable step Aktifkan langkah + Expand all Perluas semua + Export Action… Ekspor Action… + Finish or cancel action playback and the pending step first Selesaikan atau batalkan dahulu pemutaran action dan langkah yang tertunda + Finishing step… Menyelesaikan langkah… + Generated layer effects Efek layer yang dihasilkan + Generated styles Gaya yang dihasilkan + Grey Abu-abu + Harmonize layers Selaraskan layer + Hue and saturation Rona dan saturasi + Import Actions… Impor Action… + Import actions or create a new action to get started. Impor action atau buat action baru untuk memulai. + Insert Action Sisipkan Action + Insert Conditional Sisipkan kondisi + Insert Menu Item Sisipkan item menu + Insert Path Sisipkan jalur + Insert Stop Sisipkan Berhenti + Insert… Sisipkan… + Keyboard shortcut Pintasan keyboard + Lavender Lavender + Layer effects Efek layer + Light blue Biru Muda + List size Ukuran daftar + Magic wand Tongkat ajaib + Merge layers Gabungkan Layer + Move step down Pindahkan langkah ke bawah + Move step up Pindahkan langkah ke atas + New group Grup Baru + No steps yet Belum ada langkah + Object selection Pemilihan objek + On Aktif + Play Action Putar action + Play Actions Putar semua action + Play action Putar action + Play from selected step Putar dari langkah yang dipilih + Playback Options… Opsi Pemutaran… + Play… Putar… + Preview on hover Pratinjau saat penunjuk diarahkan + Record Rekam + Recording Merekam + Remove background Hapus Background + Save Action Simpan Action + Search actions Cari action + Select an existing action step to play Pilih langkah action yang sudah ada untuk diputar + Select subject Pilih Subjek + Show grid Tampilkan Grid + Show step settings Tampilkan pengaturan langkah + Smart removal Penghapusan cerdas + Sort & View options Opsi Urutan & Tampilan + Start recording to build your action Mulai merekam untuk membuat action Anda + Steps Langkah + Stored action step Langkah action tersimpan + Stored settings Pengaturan tersimpan + Teal Hijau kebiruan + Text Teks + Text colour Warna teks + The draft steps changed. Cancel this dialog and reopen the step. Langkah draf berubah. Batalkan dialog ini dan buka kembali langkah tersebut. + The dragged action changed; drag it again Action yang diseret berubah; seret lagi + The editor response does not belong to this draft Respons editor bukan milik draf ini + There are unsaved changes. Do you want to save or discard them? Ada perubahan yang belum disimpan. Simpan atau buang perubahan? + This editor page is closed Halaman editor ini ditutup + This step dialog belongs to another editor Dialog langkah ini milik editor lain + Wait for the current action step to finish Tunggu hingga langkah action saat ini selesai + You haven't created any actions Anda belum membuat action apa pun + Yours Milik Anda + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Action tanpa grup diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 367c7267f57..79cee94e0ea 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4632,3 +4632,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Colore del punto Shapes can't use patterns yet Le forme non possono ancora usare pattern Edit Brush… Modifica pennello… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} L’azione è stata salvata, ma non è stato possibile salvare la preferenza di conferma: {error} + Add a brief description for this action… Aggiungi una breve descrizione di questa azione… + Allow Tool Recording Consenti la registrazione degli strumenti + Answer the open Actions editor question first Rispondi prima alla domanda aperta nell’editor delle azioni + Apply to background Applica allo sfondo + Apply to subject Applica al soggetto + Black and white Bianco e nero + Cancel and continue editing Annulla e continua a modificare + Choose color Scegli colore + Choose icon Scegli icona + Classic Actions Azioni classiche + Clear All Actions Cancella tutte le azioni + Clear search Cancella ricerca + Click action Azione di clic + Close the open Actions editor first Chiudi prima l’editor delle azioni aperto + Collapse all Comprimi tutto + Colour lookup tables Tabelle di corrispondenza dei colori + Confirm or cancel the open action dialog first Conferma o annulla prima la finestra di dialogo dell’azione aperta + Control Ctrl + Default icon Icona predefinita + Delete Action Elimina azione + Delete action Elimina azione + Delete group Elimina gruppo + Delete step Elimina passaggio + Delete the action "{name}"? Eliminare l’azione «{name}»? + Delete the group "{name}"? Eliminare il gruppo «{name}»? + Discard Scarta + Download Scarica + Duplicate Action Duplica azione + Duplicate action Duplica azione + Duplicate group Duplica gruppo + Duplicate step Duplica passaggio + Edit Action Modifica azione + Edit action Modifica azione + Edit step settings Modifica impostazioni del passaggio + Enable step Abilita passaggio + Expand all Espandi tutto + Export Action… Esporta azione… + Finish or cancel action playback and the pending step first Completa o annulla prima la riproduzione dell’azione e il passaggio in sospeso + Finishing step… Completamento del passaggio… + Generated layer effects Effetti di livello generati + Generated styles Stili generati + Grey Grigio + Harmonize layers Armonizza livelli + Hue and saturation Tonalità e saturazione + Import Actions… Importa azioni… + Import actions or create a new action to get started. Importa azioni o crea una nuova azione per iniziare. + Insert Action Inserisci azione + Insert Conditional Inserisci condizione + Insert Menu Item Inserisci voce di menu + Insert Path Inserisci tracciato + Insert Stop Inserisci interruzione + Insert… Inserisci… + Keyboard shortcut Scorciatoia da tastiera + Lavender Lavanda + Layer effects Effetti di livello + Light blue Blu chiaro + List size Dimensione elenco + Magic wand Bacchetta magica + Merge layers Unisci livelli + Move step down Sposta passaggio in basso + Move step up Sposta passaggio in alto + New group Nuovo gruppo + No steps yet Ancora nessun passaggio + Object selection Selezione oggetto + Play Action Riproduci azione + Play Actions Riproduci azioni + Play action Riproduci azione + Play from selected step Riproduci dal passaggio selezionato + Playback Options… Opzioni di riproduzione… + Play… Riproduci… + Preview on hover Anteprima al passaggio del puntatore + Record Registra + Recording Registrazione + Remove background Rimuovi sfondo + Save Action Salva azione + Search actions Cerca azioni + Select an existing action step to play Seleziona un passaggio di azione esistente da riprodurre + Select subject Seleziona soggetto + Show grid Mostra griglia + Show step settings Mostra impostazioni del passaggio + Smart removal Rimozione intelligente + Sort & View options Opzioni di ordinamento e visualizzazione + Start recording to build your action Avvia la registrazione per creare la tua azione + Steps Passaggi + Stored action step Passaggio di azione memorizzato + Stored settings Impostazioni memorizzate + Teal Verde petrolio + Text Testo + Text colour Colore del testo + The draft steps changed. Cancel this dialog and reopen the step. I passaggi della bozza sono cambiati. Annulla questa finestra di dialogo e riapri il passaggio. + The dragged action changed; drag it again L’azione trascinata è cambiata; trascinala di nuovo + The editor response does not belong to this draft La risposta dell’editor non appartiene a questa bozza + There are unsaved changes. Do you want to save or discard them? Ci sono modifiche non salvate. Vuoi salvarle o scartarle? + This editor page is closed Questa pagina dell’editor è chiusa + This step dialog belongs to another editor Questa finestra di dialogo del passaggio appartiene a un altro editor + Wait for the current action step to finish Attendi il completamento del passaggio di azione corrente + You haven't created any actions Non hai ancora creato azioni + Yours Le tue azioni + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Azioni non raggruppate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2eee5145a23..1daa190c1c5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4197,3 +4197,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color 分岐点のカラー Shapes can't use patterns yet シェイプではまだパターンを使用できません Edit Brush… ブラシを編集… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} アクションは保存されましたが、確認の設定を保存できませんでした: {error} + Add a brief description for this action… このアクションの簡単な説明を追加… + Allow Tool Recording ツール操作の記録を許可 + Answer the open Actions editor question first まずアクションエディターで開いている質問に回答してください + Apply to background 背景に適用 + Apply to subject 被写体に適用 + Back 戻る + Black and white 白黒 + Cancel and continue editing キャンセルして編集を続ける + Choose color 色を選択 + Choose icon アイコンを選択 + Classic Actions クラシックアクション + Clear All Actions すべてのアクションを消去 + Clear search 検索をクリア + Click action クリック操作 + Close the open Actions editor first まず開いているアクションエディターを閉じてください + Collapse all すべて折りたたむ + Colour lookup tables カラールックアップテーブル + Confirm or cancel the open action dialog first まず開いているアクションのダイアログを確定またはキャンセルしてください + Control Ctrl + Default icon 既定のアイコン + Delete Action アクションを削除 + Delete action アクションを削除 + Delete group グループを削除 + Delete step ステップを削除 + Delete the action "{name}"? アクション「{name}」を削除しますか? + Delete the group "{name}"? グループ「{name}」を削除しますか? + Discard 破棄 + Download ダウンロード + Duplicate Action アクションを複製 + Duplicate action アクションを複製 + Duplicate group グループを複製 + Duplicate step ステップを複製 + Edit Action アクションを編集 + Edit action アクションを編集 + Edit step settings ステップの設定を編集 + Enable step ステップを有効にする + Expand all すべて展開 + Export Action… アクションを書き出し… + Finish or cancel action playback and the pending step first まずアクションの再生と保留中のステップを完了またはキャンセルしてください + Finishing step… ステップを完了中… + Generated layer effects 生成されたレイヤー効果 + Generated styles 生成されたスタイル + Grey グレー + Harmonize layers レイヤーを調和 + Hue and saturation 色相と彩度 + Import Actions… アクションを読み込み… + Import actions or create a new action to get started. アクションを読み込むか、新しいアクションを作成して始めましょう。 + Insert Action アクションを挿入 + Insert Conditional 条件を挿入 + Insert Menu Item メニュー項目を挿入 + Insert Path パスを挿入 + Insert Stop ストップを挿入 + Insert… 挿入… + Keyboard shortcut キーボードショートカット + Lavender ラベンダー + Layer effects レイヤー効果 + Light blue ライトブルー + List size リストのサイズ + Magic wand 自動選択 + Merge layers レイヤーを結合 + Move step down ステップを下に移動 + Move step up ステップを上に移動 + New group 新規グループ + No steps yet まだステップがありません + Object selection オブジェクト選択 + On オン + Play Action アクションを再生 + Play Actions アクションを再生 + Play action アクションを再生 + Play from selected step 選択したステップから再生 + Playback Options… 再生オプション… + Play… 再生… + Preview on hover ホバー時にプレビュー + Record 記録 + Recording 記録中 + Remove background 背景を削除 + Save Action アクションを保存 + Search actions アクションを検索 + Select an existing action step to play 再生する既存のアクションステップを選択してください + Select subject 被写体を選択 + Show grid グリッドを表示 + Show step settings ステップの設定を表示 + Smart removal スマート削除 + Sort & View options 並べ替えと表示のオプション + Start recording to build your action 記録を開始してアクションを作成しましょう + Steps ステップ + Stored action step 保存済みのアクションステップ + Stored settings 保存済みの設定 + Teal 青緑 + Text テキスト + Text colour 文字色 + The draft steps changed. Cancel this dialog and reopen the step. 下書きのステップが変更されました。このダイアログをキャンセルし、ステップを開き直してください。 + The dragged action changed; drag it again ドラッグしたアクションが変更されました。もう一度ドラッグしてください + The editor response does not belong to this draft エディターの応答はこの下書きに属していません + There are unsaved changes. Do you want to save or discard them? 未保存の変更があります。保存しますか、破棄しますか? + This editor page is closed このエディターページは閉じられています + This step dialog belongs to another editor このステップのダイアログは別のエディターに属しています + Wait for the current action step to finish 現在のアクションステップが完了するまでお待ちください + You haven't created any actions まだアクションを作成していません + Yours マイアクション + +# Actions reserved category caption (10945/10946) + Ungrouped Actions 未分類のアクション diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 43f7dea83e9..9cea628b3ba 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3740,3 +3740,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color 정지점 색상 Shapes can't use patterns yet 모양에는 아직 패턴을 사용할 수 없습니다 Edit Brush… 브러시 편집… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} 액션을 저장했지만 확인 설정을 저장하지 못했습니다: {error} + Add a brief description for this action… 이 액션에 대한 간단한 설명 추가… + Allow Tool Recording 도구 기록 허용 + Answer the open Actions editor question first 먼저 액션 편집기에 열린 질문에 답하세요 + Apply to background 배경에 적용 + Apply to subject 피사체에 적용 + Back 뒤로 + Black and white 흑백 + Cancel and continue editing 취소하고 계속 편집 + Choose color 색상 선택 + Choose icon 아이콘 선택 + Classic Actions 클래식 액션 + Clear All Actions 모든 액션 지우기 + Clear search 검색 지우기 + Click action 클릭 동작 + Close the open Actions editor first 먼저 열려 있는 액션 편집기를 닫으세요 + Collapse all 모두 접기 + Colour lookup tables 색상 조회 테이블 + Confirm or cancel the open action dialog first 먼저 열려 있는 액션 대화 상자를 확인하거나 취소하세요 + Control Ctrl + Default icon 기본 아이콘 + Delete Action 액션 삭제 + Delete action 액션 삭제 + Delete group 그룹 삭제 + Delete step 단계 삭제 + Delete the action "{name}"? 액션 “{name}”을(를) 삭제할까요? + Delete the group "{name}"? 그룹 “{name}”을(를) 삭제할까요? + Discard 버리기 + Download 다운로드 + Duplicate Action 액션 복제 + Duplicate action 액션 복제 + Duplicate group 그룹 복제 + Duplicate step 단계 복제 + Edit Action 액션 편집 + Edit action 액션 편집 + Edit step settings 단계 설정 편집 + Enable step 단계 활성화 + Expand all 모두 펼치기 + Export Action… 액션 내보내기… + Finish or cancel action playback and the pending step first 먼저 액션 재생과 대기 중인 단계를 완료하거나 취소하세요 + Finishing step… 단계 마무리 중… + Generated layer effects 생성된 레이어 효과 + Generated styles 생성된 스타일 + Grey 회색 + Harmonize layers 레이어 조화 + Hue and saturation 색조 및 채도 + Import Actions… 액션 가져오기… + Import actions or create a new action to get started. 액션을 가져오거나 새 액션을 만들어 시작하세요. + Insert Action 액션 삽입 + Insert Conditional 조건 삽입 + Insert Menu Item 메뉴 항목 삽입 + Insert Path 패스 삽입 + Insert Stop 정지 삽입 + Insert… 삽입… + Keyboard shortcut 키보드 단축키 + Lavender 라벤더 + Layer effects 레이어 효과 + Light blue 연한 파랑 + List size 목록 크기 + Magic wand 자동 선택 + Merge layers 레이어 병합 + Move step down 단계 아래로 이동 + Move step up 단계 위로 이동 + New group 새 그룹 + No steps yet 아직 단계가 없습니다 + Object selection 개체 선택 + On 켜짐 + Play Action 액션 재생 + Play Actions 액션 재생 + Play action 액션 재생 + Play from selected step 선택한 단계부터 재생 + Playback Options… 재생 옵션… + Play… 재생… + Preview on hover 마우스를 올리면 미리 보기 + Record 기록 + Recording 기록 중 + Remove background 배경 제거 + Save Action 액션 저장 + Search actions 액션 검색 + Select an existing action step to play 재생할 기존 액션 단계를 선택하세요 + Select subject 피사체 선택 + Show grid 격자 표시 + Show step settings 단계 설정 표시 + Smart removal 스마트 제거 + Sort & View options 정렬 및 보기 옵션 + Start recording to build your action 기록을 시작하여 액션을 만드세요 + Steps 단계 + Stored action step 저장된 액션 단계 + Stored settings 저장된 설정 + Teal 청록색 + Text 텍스트 + Text colour 텍스트 색상 + The draft steps changed. Cancel this dialog and reopen the step. 초안 단계가 변경되었습니다. 이 대화 상자를 취소하고 단계를 다시 여세요. + The dragged action changed; drag it again 드래그한 액션이 변경되었습니다. 다시 드래그하세요 + The editor response does not belong to this draft 편집기 응답이 이 초안에 속하지 않습니다 + There are unsaved changes. Do you want to save or discard them? 저장하지 않은 변경 사항이 있습니다. 저장할까요, 버릴까요? + This editor page is closed 이 편집기 페이지는 닫혀 있습니다 + This step dialog belongs to another editor 이 단계 대화 상자는 다른 편집기에 속합니다 + Wait for the current action step to finish 현재 액션 단계가 끝날 때까지 기다리세요 + You haven't created any actions 아직 만든 액션이 없습니다 + Yours 내 액션 + +# Actions reserved category caption (10945/10946) + Ungrouped Actions 그룹화되지 않은 액션 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index d7232004b6e..4f56d00d800 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4773,3 +4773,107 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert The path was discarded Banen ble forkastet The path was kept as the Work Path Banen ble beholdt som arbeidsbane Edit Brush… Rediger pensel… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Handlingen ble lagret, men bekreftelsesinnstillingen kunne ikke lagres: {error} + Add a brief description for this action… Legg til en kort beskrivelse av denne handlingen… + Allow Tool Recording Tillat opptak av verktøy + Answer the open Actions editor question first Svar først på det åpne spørsmålet i handlingsredigereren + Apply to background Bruk på bakgrunnen + Apply to subject Bruk på motivet + Black and white Svart-hvitt + Cancel and continue editing Avbryt og fortsett å redigere + Choose color Velg farge + Choose icon Velg ikon + Classic Actions Klassiske handlinger + Clear All Actions Tøm alle handlinger + Clear search Tøm søket + Click action Klikkhandling + Close the open Actions editor first Lukk først den åpne handlingsredigereren + Collapse all Fold sammen alle + Colour lookup tables Fargeoppslagstabeller + Confirm or cancel the open action dialog first Bekreft eller avbryt først den åpne handlingsdialogen + Control Ctrl + Default icon Standardikon + Delete Action Slett handling + Delete action Slett handling + Delete group Slett gruppe + Delete step Slett trinn + Delete the action "{name}"? Slette handlingen «{name}»? + Delete the group "{name}"? Slette gruppen «{name}»? + Discard Forkast + Download Last ned + Duplicate Action Dupliser handling + Duplicate action Dupliser handling + Duplicate group Dupliser gruppe + Duplicate step Dupliser trinn + Edit Action Rediger handling + Edit action Rediger handling + Edit step settings Rediger trinninnstillinger + Enable step Aktiver trinn + Expand all Utvid alle + Export Action… Eksporter handling… + Finish or cancel action playback and the pending step first Fullfør eller avbryt først avspillingen av handlingen og det ventende trinnet + Finishing step… Fullfører trinn… + Generated layer effects Genererte lageffekter + Generated styles Genererte stiler + Grey Grå + Harmonize layers Harmoniser lag + Hue and saturation Fargetone og metning + Import Actions… Importer handlinger… + Import actions or create a new action to get started. Importer handlinger eller opprett en ny handling for å komme i gang. + Insert Action Sett inn handling + Insert Conditional Sett inn betingelse + Insert Menu Item Sett inn menyelement + Insert Path Sett inn bane + Insert Stop Sett inn stopp + Insert… Sett inn… + Keyboard shortcut Hurtigtast + Lavender Lavendel + Layer effects Lageffekter + Light blue Lyseblå + List size Listestørrelse + Magic wand Tryllestav + Merge layers Slå sammen lag + Move step down Flytt trinn ned + Move step up Flytt trinn opp + New group Ny gruppe + No steps yet Ingen trinn ennå + Object selection Objektvalg + Play Action Spill av handling + Play Actions Spill av handlinger + Play action Spill av handling + Play from selected step Spill av fra valgt trinn + Playback Options… Avspillingsalternativer… + Play… Spill av… + Preview on hover Forhåndsvis når pekeren holdes over + Record Ta opp + Recording Opptak + Remove background Fjern bakgrunn + Save Action Lagre handling + Search actions Søk etter handlinger + Select an existing action step to play Velg et eksisterende handlingstrinn som skal spilles av + Select subject Velg motiv + Show grid Vis rutenett + Show step settings Vis trinninnstillinger + Smart removal Smart fjerning + Sort & View options Sorterings- og visningsalternativer + Start recording to build your action Start opptaket for å opprette handlingen din + Steps Trinn + Stored action step Lagret handlingstrinn + Stored settings Lagrede innstillinger + Teal Blågrønn + Text Tekst + Text colour Tekstfarge + The draft steps changed. Cancel this dialog and reopen the step. Trinnene i utkastet er endret. Avbryt denne dialogen og åpne trinnet på nytt. + The dragged action changed; drag it again Handlingen som dras, er endret; dra den på nytt + The editor response does not belong to this draft Svaret fra redigereren tilhører ikke dette utkastet + There are unsaved changes. Do you want to save or discard them? Det finnes ulagrede endringer. Vil du lagre eller forkaste dem? + This editor page is closed Denne redigeringssiden er lukket + This step dialog belongs to another editor Denne trinndialogen tilhører en annen redigerer + Wait for the current action step to finish Vent til det gjeldende handlingstrinnet er ferdig + You haven't created any actions Du har ikke opprettet noen handlinger + Yours Dine + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Ugrupperte handlinger diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 7ad2900a287..c6475b57d65 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4277,3 +4277,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Kleur van het punt Shapes can't use patterns yet Vormen kunnen nog geen patronen gebruiken Edit Brush… Penseel bewerken… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} De actie is opgeslagen, maar de bevestigingsvoorkeur kon niet worden opgeslagen: {error} + Add a brief description for this action… Voeg een korte beschrijving van deze actie toe… + Allow Tool Recording Gereedschapsopname toestaan + Answer the open Actions editor question first Beantwoord eerst de openstaande vraag in de actie-editor + Apply to background Toepassen op achtergrond + Apply to subject Toepassen op onderwerp + Black and white Zwart-wit + Cancel and continue editing Annuleren en verder bewerken + Choose color Kleur kiezen + Choose icon Pictogram kiezen + Classic Actions Klassieke acties + Clear All Actions Alle acties wissen + Clear search Zoekopdracht wissen + Click action Klikactie + Close the open Actions editor first Sluit eerst de geopende actie-editor + Collapse all Alles samenvouwen + Colour lookup tables Kleuropzoektabellen + Confirm or cancel the open action dialog first Bevestig of annuleer eerst het geopende actiedialoogvenster + Control Ctrl + Default icon Standaardpictogram + Delete Action Actie verwijderen + Delete action Actie verwijderen + Delete group Groep verwijderen + Delete step Stap verwijderen + Delete the action "{name}"? De actie ‘{name}’ verwijderen? + Delete the group "{name}"? De groep ‘{name}’ verwijderen? + Discard Verwerpen + Download Downloaden + Duplicate Action Actie dupliceren + Duplicate action Actie dupliceren + Duplicate group Groep dupliceren + Duplicate step Stap dupliceren + Edit Action Actie bewerken + Edit action Actie bewerken + Edit step settings Stapinstellingen bewerken + Enable step Stap inschakelen + Expand all Alles uitvouwen + Export Action… Actie exporteren… + Finish or cancel action playback and the pending step first Voltooi of annuleer eerst het afspelen van de actie en de openstaande stap + Finishing step… Stap afronden… + Generated layer effects Gegenereerde laageffecten + Generated styles Gegenereerde stijlen + Grey Grijs + Harmonize layers Lagen harmoniseren + Hue and saturation Kleurtoon en verzadiging + Import Actions… Acties importeren… + Import actions or create a new action to get started. Importeer acties of maak een nieuwe actie om te beginnen. + Insert Action Actie invoegen + Insert Conditional Voorwaarde invoegen + Insert Menu Item Menu-item invoegen + Insert Path Pad invoegen + Insert Stop Stop invoegen + Insert… Invoegen… + Keyboard shortcut Sneltoets + Lavender Lavendel + Layer effects Laageffecten + Light blue Lichtblauw + List size Lijstgrootte + Magic wand Toverstaf + Merge layers Lagen samenvoegen + Move step down Stap omlaag verplaatsen + Move step up Stap omhoog verplaatsen + New group Nieuwe groep + No steps yet Nog geen stappen + Object selection Objectselectie + Play Action Actie afspelen + Play Actions Acties afspelen + Play action Actie afspelen + Play from selected step Vanaf geselecteerde stap afspelen + Playback Options… Afspeelopties… + Play… Afspelen… + Preview on hover Voorbeeld bij aanwijzen + Record Opnemen + Recording Opname + Remove background Achtergrond verwijderen + Save Action Actie opslaan + Search actions Acties zoeken + Select an existing action step to play Selecteer een bestaande actiestap om af te spelen + Select subject Onderwerp selecteren + Show grid Raster tonen + Show step settings Stapinstellingen tonen + Smart removal Slim verwijderen + Sort & View options Sorteer- en weergaveopties + Start recording to build your action Start de opname om uw actie te maken + Steps Stappen + Stored action step Opgeslagen actiestap + Stored settings Opgeslagen instellingen + Teal Blauwgroen + Text Tekst + Text colour Tekstkleur + The draft steps changed. Cancel this dialog and reopen the step. De conceptstappen zijn gewijzigd. Annuleer dit dialoogvenster en open de stap opnieuw. + The dragged action changed; drag it again De gesleepte actie is gewijzigd; sleep deze opnieuw + The editor response does not belong to this draft Het antwoord van de editor hoort niet bij dit concept + There are unsaved changes. Do you want to save or discard them? Er zijn niet-opgeslagen wijzigingen. Wilt u deze opslaan of verwerpen? + This editor page is closed Deze editorpagina is gesloten + This step dialog belongs to another editor Dit stapdialoogvenster hoort bij een andere editor + Wait for the current action step to finish Wacht tot de huidige actiestap is voltooid + You haven't created any actions U hebt nog geen acties gemaakt + Yours Uw acties + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Ongegroepeerde acties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fe13d8f1778..3c492e1b7a9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4139,3 +4139,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Kolor punktu Shapes can't use patterns yet Kształty nie mogą jeszcze używać wzorków Edit Brush… Edytuj pędzel… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Akcja została zapisana, ale nie udało się zapisać preferencji potwierdzania: {error} + Add a brief description for this action… Dodaj krótki opis tej akcji… + Allow Tool Recording Zezwalaj na nagrywanie narzędzi + Answer the open Actions editor question first Najpierw odpowiedz na otwarte pytanie w edytorze akcji + Apply to background Zastosuj do tła + Apply to subject Zastosuj do obiektu + Back Wstecz + Black and white Czarno-białe + Cancel and continue editing Anuluj i kontynuuj edycję + Choose color Wybierz kolor + Choose icon Wybierz ikonę + Classic Actions Klasyczne akcje + Clear All Actions Wyczyść wszystkie akcje + Clear search Wyczyść wyszukiwanie + Click action Kliknięcie + Close the open Actions editor first Najpierw zamknij otwarty edytor akcji + Collapse all Zwiń wszystko + Colour lookup tables Tablice przekształcania kolorów + Confirm or cancel the open action dialog first Najpierw zatwierdź lub anuluj otwarte okno dialogowe akcji + Control Ctrl + Default icon Domyślna ikona + Delete Action Usuń akcję + Delete action Usuń akcję + Delete group Usuń grupę + Delete step Usuń krok + Delete the action "{name}"? Usunąć akcję „{name}”? + Delete the group "{name}"? Usunąć grupę „{name}”? + Discard Odrzuć + Download Pobierz + Duplicate Action Powiel akcję + Duplicate action Powiel akcję + Duplicate group Powiel grupę + Duplicate step Powiel krok + Edit Action Edytuj akcję + Edit action Edytuj akcję + Edit step settings Edytuj ustawienia kroku + Enable step Włącz krok + Expand all Rozwiń wszystko + Export Action… Eksportuj akcję… + Finish or cancel action playback and the pending step first Najpierw zakończ lub anuluj odtwarzanie akcji i oczekujący krok + Finishing step… Kończenie kroku… + Generated layer effects Wygenerowane efekty warstwy + Generated styles Wygenerowane style + Grey Szary + Harmonize layers Harmonizuj warstwy + Hue and saturation Barwa i nasycenie + Import Actions… Importuj akcje… + Import actions or create a new action to get started. Zaimportuj akcje lub utwórz nową akcję, aby rozpocząć. + Insert Action Wstaw akcję + Insert Conditional Wstaw warunek + Insert Menu Item Wstaw element menu + Insert Path Wstaw ścieżkę + Insert Stop Wstaw zatrzymanie + Insert… Wstaw… + Keyboard shortcut Skrót klawiaturowy + Lavender Lawendowy + Layer effects Efekty warstwy + Light blue Jasnoniebieski + List size Rozmiar listy + Magic wand Różdżka + Merge layers Scal warstwy + Move step down Przesuń krok w dół + Move step up Przesuń krok w górę + New group Nowa grupa + No steps yet Brak kroków + Object selection Zaznaczanie obiektów + On Wł. + Play Action Odtwórz akcję + Play Actions Odtwórz akcje + Play action Odtwórz akcję + Play from selected step Odtwórz od wybranego kroku + Playback Options… Opcje odtwarzania… + Play… Odtwórz… + Preview on hover Podgląd po najechaniu + Record Nagrywaj + Recording Nagrywanie + Remove background Usuń tło + Save Action Zapisz akcję + Search actions Szukaj akcji + Select an existing action step to play Wybierz istniejący krok akcji do odtworzenia + Select subject Zaznacz obiekt główny + Show grid Pokaż siatkę + Show step settings Pokaż ustawienia kroku + Smart removal Inteligentne usuwanie + Sort & View options Opcje sortowania i widoku + Start recording to build your action Rozpocznij nagrywanie, aby utworzyć akcję + Steps Kroki + Stored action step Zapisany krok akcji + Stored settings Zapisane ustawienia + Teal Morski + Text Tekst + Text colour Kolor tekstu + The draft steps changed. Cancel this dialog and reopen the step. Kroki wersji roboczej uległy zmianie. Anuluj to okno dialogowe i ponownie otwórz krok. + The dragged action changed; drag it again Przeciągana akcja uległa zmianie; przeciągnij ją ponownie + The editor response does not belong to this draft Odpowiedź edytora nie należy do tej wersji roboczej + There are unsaved changes. Do you want to save or discard them? Istnieją niezapisane zmiany. Zapisać je czy odrzucić? + This editor page is closed Ta strona edytora jest zamknięta + This step dialog belongs to another editor To okno dialogowe kroku należy do innego edytora + Wait for the current action step to finish Poczekaj na zakończenie bieżącego kroku akcji + You haven't created any actions Nie utworzono jeszcze żadnych akcji + Yours Twoje + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Akcje bez grupy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f12a596d1a3..5bcb4135245 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4447,3 +4447,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Cor da parada Shapes can't use patterns yet Formas ainda não podem usar padrões Edit Brush… Editar pincel… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} A ação foi salva, mas não foi possível salvar a preferência de confirmação: {error} + Add a brief description for this action… Adicione uma breve descrição desta ação… + Allow Tool Recording Permitir gravação de ferramentas + Answer the open Actions editor question first Responda primeiro à pergunta aberta no editor de ações + Apply to background Aplicar ao plano de fundo + Apply to subject Aplicar ao assunto + Black and white Preto e branco + Cancel and continue editing Cancelar e continuar editando + Choose color Escolher cor + Choose icon Escolher ícone + Classic Actions Ações clássicas + Clear All Actions Limpar todas as ações + Clear search Limpar pesquisa + Click action Ação de clique + Close the open Actions editor first Feche primeiro o editor de ações aberto + Collapse all Recolher tudo + Colour lookup tables Tabelas de consulta de cores + Confirm or cancel the open action dialog first Confirme ou cancele primeiro a caixa de diálogo de ação aberta + Control Ctrl + Default icon Ícone padrão + Delete Action Excluir ação + Delete action Excluir ação + Delete group Excluir grupo + Delete step Excluir etapa + Delete the action "{name}"? Excluir a ação “{name}”? + Delete the group "{name}"? Excluir o grupo “{name}”? + Discard Descartar + Download Baixar + Duplicate Action Duplicar ação + Duplicate action Duplicar ação + Duplicate group Duplicar grupo + Duplicate step Duplicar etapa + Edit Action Editar ação + Edit action Editar ação + Edit step settings Editar configurações da etapa + Enable step Ativar etapa + Expand all Expandir tudo + Export Action… Exportar ação… + Finish or cancel action playback and the pending step first Conclua ou cancele primeiro a reprodução da ação e a etapa pendente + Finishing step… Concluindo etapa… + Generated layer effects Efeitos de camada gerados + Generated styles Estilos gerados + Grey Cinza + Harmonize layers Harmonizar camadas + Hue and saturation Matiz e saturação + Import Actions… Importar ações… + Import actions or create a new action to get started. Importe ações ou crie uma nova ação para começar. + Insert Action Inserir ação + Insert Conditional Inserir condição + Insert Menu Item Inserir item de menu + Insert Path Inserir caminho + Insert Stop Inserir parada + Insert… Inserir… + Keyboard shortcut Atalho de teclado + Lavender Lavanda + Layer effects Efeitos de camada + Light blue Azul-claro + List size Tamanho da lista + Magic wand Varinha mágica + Merge layers Mesclar camadas + Move step down Mover etapa para baixo + Move step up Mover etapa para cima + New group Novo grupo + No steps yet Ainda não há etapas + Object selection Seleção de objetos + Play Action Reproduzir ação + Play Actions Reproduzir ações + Play action Reproduzir ação + Play from selected step Reproduzir a partir da etapa selecionada + Playback Options… Opções de reprodução… + Play… Reproduzir… + Preview on hover Visualizar ao passar o cursor + Record Gravar + Recording Gravando + Remove background Remover plano de fundo + Save Action Salvar ação + Search actions Pesquisar ações + Select an existing action step to play Selecione uma etapa de ação existente para reproduzir + Select subject Selecionar assunto + Show grid Mostrar grade + Show step settings Mostrar configurações da etapa + Smart removal Remoção inteligente + Sort & View options Opções de classificação e visualização + Start recording to build your action Inicie a gravação para criar sua ação + Steps Etapas + Stored action step Etapa de ação armazenada + Stored settings Configurações armazenadas + Teal Verde-azulado + Text Texto + Text colour Cor do texto + The draft steps changed. Cancel this dialog and reopen the step. As etapas do rascunho foram alteradas. Cancele esta caixa de diálogo e abra a etapa novamente. + The dragged action changed; drag it again A ação arrastada foi alterada; arraste-a novamente + The editor response does not belong to this draft A resposta do editor não pertence a este rascunho + There are unsaved changes. Do you want to save or discard them? Há alterações não salvas. Deseja salvá-las ou descartá-las? + This editor page is closed Esta página do editor está fechada + This step dialog belongs to another editor Esta caixa de diálogo de etapa pertence a outro editor + Wait for the current action step to finish Aguarde a conclusão da etapa de ação atual + You haven't created any actions Você ainda não criou nenhuma ação + Yours Suas ações + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Ações sem grupo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 60f3929e5f7..bac5dd8f562 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4717,3 +4717,107 @@ shape Appearance Оформление Stop Color Цвет контрольной точки Shapes can't use patterns yet Фигуры пока не могут использовать узоры Edit Brush… Изменить кисть… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Операция сохранена, но настройку подтверждения сохранить не удалось: {error} + Add a brief description for this action… Добавьте краткое описание этой операции… + Allow Tool Recording Разрешить запись инструментов + Answer the open Actions editor question first Сначала ответьте на открытый вопрос в редакторе операций + Apply to background Применить к фону + Apply to subject Применить к объекту + Black and white Чёрно-белое + Cancel and continue editing Отменить и продолжить редактирование + Choose color Выбрать цвет + Choose icon Выбрать значок + Classic Actions Классические операции + Clear All Actions Очистить все операции + Clear search Очистить поиск + Click action Щелчок + Close the open Actions editor first Сначала закройте открытый редактор операций + Collapse all Свернуть всё + Colour lookup tables Таблицы преобразования цветов + Confirm or cancel the open action dialog first Сначала подтвердите или отмените открытый диалог операции + Control Ctrl + Default icon Значок по умолчанию + Delete Action Удалить операцию + Delete action Удалить операцию + Delete group Удалить группу + Delete step Удалить шаг + Delete the action "{name}"? Удалить операцию «{name}»? + Delete the group "{name}"? Удалить группу «{name}»? + Discard Отбросить + Download Скачать + Duplicate Action Дублировать операцию + Duplicate action Дублировать операцию + Duplicate group Дублировать группу + Duplicate step Дублировать шаг + Edit Action Изменить операцию + Edit action Изменить операцию + Edit step settings Изменить настройки шага + Enable step Включить шаг + Expand all Развернуть всё + Export Action… Экспортировать операцию… + Finish or cancel action playback and the pending step first Сначала завершите или отмените воспроизведение операции и ожидающий шаг + Finishing step… Завершение шага… + Generated layer effects Сгенерированные эффекты слоя + Generated styles Сгенерированные стили + Grey Серый + Harmonize layers Согласовать слои + Hue and saturation Цветовой тон и насыщенность + Import Actions… Импортировать операции… + Import actions or create a new action to get started. Импортируйте операции или создайте новую, чтобы начать. + Insert Action Вставить операцию + Insert Conditional Вставить условие + Insert Menu Item Вставить пункт меню + Insert Path Вставить контур + Insert Stop Вставить остановку + Insert… Вставить… + Keyboard shortcut Сочетание клавиш + Lavender Лавандовый + Layer effects Эффекты слоя + Light blue Светло-синий + List size Размер списка + Magic wand Волшебная палочка + Merge layers Объединить слои + Move step down Переместить шаг вниз + Move step up Переместить шаг вверх + New group Новая группа + No steps yet Шагов пока нет + Object selection Выделение объекта + Play Action Воспроизвести операцию + Play Actions Воспроизвести операции + Play action Воспроизвести операцию + Play from selected step Воспроизвести с выбранного шага + Playback Options… Параметры воспроизведения… + Play… Воспроизвести… + Preview on hover Предпросмотр при наведении + Record Записать + Recording Запись + Remove background Удалить фон + Save Action Сохранить операцию + Search actions Поиск операций + Select an existing action step to play Выберите существующий шаг операции для воспроизведения + Select subject Выбрать объект + Show grid Показать сетку + Show step settings Показать настройки шага + Smart removal Умное удаление + Sort & View options Параметры сортировки и вида + Start recording to build your action Начните запись, чтобы создать операцию + Steps Шаги + Stored action step Сохранённый шаг операции + Stored settings Сохранённые настройки + Teal Сине-зелёный + Text Текст + Text colour Цвет текста + The draft steps changed. Cancel this dialog and reopen the step. Шаги черновика изменились. Отмените этот диалог и снова откройте шаг. + The dragged action changed; drag it again Перетаскиваемая операция изменилась; перетащите её снова + The editor response does not belong to this draft Ответ редактора не относится к этому черновику + There are unsaved changes. Do you want to save or discard them? Есть несохранённые изменения. Сохранить их или отбросить? + This editor page is closed Эта страница редактора закрыта + This step dialog belongs to another editor Этот диалог шага относится к другому редактору + Wait for the current action step to finish Дождитесь завершения текущего шага операции + You haven't created any actions Вы ещё не создали ни одной операции + Yours Ваши + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Несгруппированные операции diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2ebc6c2e707..acea9889294 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4599,3 +4599,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Boja tačke Shapes can't use patterns yet Oblici još ne mogu da koriste šare Edit Brush… Uredi četkicu… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Radnja je sačuvana, ali podešavanje potvrde nije moglo da se sačuva: {error} + Add a brief description for this action… Dodajte kratak opis ove radnje… + Allow Tool Recording Dozvoli snimanje alata + Answer the open Actions editor question first Prvo odgovorite na otvoreno pitanje u uređivaču radnji + Apply to background Primeni na pozadinu + Apply to subject Primeni na subjekat + Black and white Crno-belo + Cancel and continue editing Otkaži i nastavi uređivanje + Choose color Izaberi boju + Choose icon Izaberi ikonu + Classic Actions Klasične radnje + Clear All Actions Obriši sve radnje + Clear search Obriši pretragu + Click action Radnja klika + Close the open Actions editor first Prvo zatvorite otvoreni uređivač radnji + Collapse all Skupi sve + Colour lookup tables Tabele preslikavanja boja + Confirm or cancel the open action dialog first Prvo potvrdite ili otkažite otvoreni dijalog radnje + Control Ctrl + Default icon Podrazumevana ikona + Delete Action Obriši radnju + Delete action Obriši radnju + Delete group Obriši grupu + Delete step Obriši korak + Delete the action "{name}"? Obrisati radnju „{name}“? + Delete the group "{name}"? Obrisati grupu „{name}“? + Discard Odbaci + Download Preuzmi + Duplicate Action Dupliraj radnju + Duplicate action Dupliraj radnju + Duplicate group Dupliraj grupu + Duplicate step Dupliraj korak + Edit Action Uredi radnju + Edit action Uredi radnju + Edit step settings Uredi podešavanja koraka + Enable step Omogući korak + Expand all Proširi sve + Export Action… Izvezi radnju… + Finish or cancel action playback and the pending step first Prvo završite ili otkažite reprodukciju radnje i korak na čekanju + Finishing step… Završavanje koraka… + Generated layer effects Generisani efekti sloja + Generated styles Generisani stilovi + Grey Siva + Harmonize layers Uskladi slojeve + Hue and saturation Nijansa i zasićenost + Import Actions… Uvezi radnje… + Import actions or create a new action to get started. Uvezite radnje ili napravite novu radnju da biste počeli. + Insert Action Umetni radnju + Insert Conditional Umetni uslov + Insert Menu Item Umetni stavku menija + Insert Path Umetni putanju + Insert Stop Umetni zaustavljanje + Insert… Umetni… + Keyboard shortcut Prečica na tastaturi + Lavender Boja lavande + Layer effects Efekti sloja + Light blue Svetloplava + List size Veličina liste + Magic wand Čarobni štapić + Merge layers Spoji slojeve + Move step down Pomeri korak nadole + Move step up Pomeri korak nagore + New group Nova grupa + No steps yet Još nema koraka + Object selection Izbor objekta + Play Action Pusti radnju + Play Actions Pusti radnje + Play action Pusti radnju + Play from selected step Pusti od izabranog koraka + Playback Options… Opcije reprodukcije… + Play… Pusti… + Preview on hover Pregled pri prelasku pokazivača + Record Snimi + Recording Snimanje + Remove background Ukloni pozadinu + Save Action Sačuvaj radnju + Search actions Pretraži radnje + Select an existing action step to play Izaberite postojeći korak radnje za reprodukciju + Select subject Izaberi subjekat + Show grid Prikaži mrežu + Show step settings Prikaži podešavanja koraka + Smart removal Pametno uklanjanje + Sort & View options Opcije sortiranja i prikaza + Start recording to build your action Pokrenite snimanje da biste napravili radnju + Steps Koraci + Stored action step Sačuvan korak radnje + Stored settings Sačuvana podešavanja + Teal Plavozelena + Text Tekst + Text colour Boja teksta + The draft steps changed. Cancel this dialog and reopen the step. Koraci nacrta su promenjeni. Otkažite ovaj dijalog i ponovo otvorite korak. + The dragged action changed; drag it again Radnja koju prevlačite je promenjena; prevucite je ponovo + The editor response does not belong to this draft Odgovor uređivača ne pripada ovom nacrtu + There are unsaved changes. Do you want to save or discard them? Postoje nesačuvane izmene. Želite li da ih sačuvate ili odbacite? + This editor page is closed Ova stranica uređivača je zatvorena + This step dialog belongs to another editor Ovaj dijalog koraka pripada drugom uređivaču + Wait for the current action step to finish Sačekajte da se završi trenutni korak radnje + You haven't created any actions Još niste napravili nijednu radnju + Yours Vaše + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Negrupisane radnje diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b9c37217658..ec0b8d604ac 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4771,3 +4771,107 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen The path was discarded Banan kasserades The path was kept as the Work Path Banan behölls som arbetsbana Edit Brush… Redigera pensel… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Åtgärden sparades, men bekräftelseinställningen kunde inte sparas: {error} + Add a brief description for this action… Lägg till en kort beskrivning av åtgärden… + Allow Tool Recording Tillåt verktygsinspelning + Answer the open Actions editor question first Besvara först den öppna frågan i åtgärdsredigeraren + Apply to background Tillämpa på bakgrunden + Apply to subject Tillämpa på motivet + Black and white Svartvitt + Cancel and continue editing Avbryt och fortsätt redigera + Choose color Välj färg + Choose icon Välj ikon + Classic Actions Klassiska åtgärder + Clear All Actions Rensa alla åtgärder + Clear search Rensa sökningen + Click action Klickåtgärd + Close the open Actions editor first Stäng först den öppna åtgärdsredigeraren + Collapse all Fäll ihop alla + Colour lookup tables Färguppslagstabeller + Confirm or cancel the open action dialog first Bekräfta eller avbryt först den öppna åtgärdsdialogrutan + Control Ctrl + Default icon Standardikon + Delete Action Ta bort åtgärd + Delete action Ta bort åtgärd + Delete group Ta bort grupp + Delete step Ta bort steg + Delete the action "{name}"? Ta bort åtgärden ”{name}”? + Delete the group "{name}"? Ta bort gruppen ”{name}”? + Discard Förkasta + Download Hämta + Duplicate Action Duplicera åtgärd + Duplicate action Duplicera åtgärd + Duplicate group Duplicera grupp + Duplicate step Duplicera steg + Edit Action Redigera åtgärd + Edit action Redigera åtgärd + Edit step settings Redigera steginställningar + Enable step Aktivera steg + Expand all Fäll ut alla + Export Action… Exportera åtgärd… + Finish or cancel action playback and the pending step first Slutför eller avbryt först åtgärdsuppspelningen och det väntande steget + Finishing step… Slutför steg… + Generated layer effects Genererade lagereffekter + Generated styles Genererade stilar + Grey Grå + Harmonize layers Harmonisera lager + Hue and saturation Nyans och mättnad + Import Actions… Importera åtgärder… + Import actions or create a new action to get started. Importera åtgärder eller skapa en ny åtgärd för att komma igång. + Insert Action Infoga åtgärd + Insert Conditional Infoga villkor + Insert Menu Item Infoga menyalternativ + Insert Path Infoga bana + Insert Stop Infoga stopp + Insert… Infoga… + Keyboard shortcut Kortkommando + Lavender Lavendel + Layer effects Lagereffekter + Light blue Ljusblå + List size Liststorlek + Magic wand Trollstav + Merge layers Sammanfoga lager + Move step down Flytta steget nedåt + Move step up Flytta steget uppåt + New group Ny grupp + No steps yet Inga steg ännu + Object selection Objektmarkering + Play Action Spela upp åtgärd + Play Actions Spela upp åtgärder + Play action Spela upp åtgärd + Play from selected step Spela upp från markerat steg + Playback Options… Uppspelningsalternativ… + Play… Spela upp… + Preview on hover Förhandsvisa vid hovring + Record Spela in + Recording Inspelning + Remove background Ta bort bakgrund + Save Action Spara åtgärd + Search actions Sök åtgärder + Select an existing action step to play Välj ett befintligt åtgärdssteg att spela upp + Select subject Markera motiv + Show grid Visa rutnät + Show step settings Visa steginställningar + Smart removal Smart borttagning + Sort & View options Sorterings- och visningsalternativ + Start recording to build your action Starta inspelningen för att skapa din åtgärd + Steps Steg + Stored action step Lagrat åtgärdssteg + Stored settings Lagrade inställningar + Teal Blågrön + Text Text + Text colour Textfärg + The draft steps changed. Cancel this dialog and reopen the step. Utkastets steg har ändrats. Avbryt den här dialogrutan och öppna steget igen. + The dragged action changed; drag it again Den dragna åtgärden har ändrats; dra den igen + The editor response does not belong to this draft Redigerarens svar hör inte till det här utkastet + There are unsaved changes. Do you want to save or discard them? Det finns osparade ändringar. Vill du spara eller förkasta dem? + This editor page is closed Den här redigerarsidan är stängd + This step dialog belongs to another editor Den här stegdialogrutan hör till en annan redigerare + Wait for the current action step to finish Vänta tills det aktuella åtgärdssteget är klart + You haven't created any actions Du har inte skapat några åtgärder + Yours Dina + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Ogrupperade åtgärder diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 007d756f894..77bdde71c27 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4254,3 +4254,107 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Durak Rengi Shapes can't use patterns yet Şekiller henüz desen kullanamaz Edit Brush… Fırçayı Düzenle… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Eylem kaydedildi, ancak onay tercihi kaydedilemedi: {error} + Add a brief description for this action… Bu eylem için kısa bir açıklama ekleyin… + Allow Tool Recording Araç kaydına izin ver + Answer the open Actions editor question first Önce Eylem düzenleyicisindeki açık soruyu yanıtlayın + Apply to background Arka plana uygula + Apply to subject Konuya uygula + Black and white Siyah beyaz + Cancel and continue editing İptal et ve düzenlemeye devam et + Choose color Renk seç + Choose icon Simge seç + Classic Actions Klasik Eylemler + Clear All Actions Tüm Eylemleri temizle + Clear search Aramayı temizle + Click action Tıklama eylemi + Close the open Actions editor first Önce açık Eylem düzenleyicisini kapatın + Collapse all Tümünü daralt + Colour lookup tables Renk eşleme tabloları + Confirm or cancel the open action dialog first Önce açık eylem iletişim kutusunu onaylayın veya iptal edin + Control Ctrl + Default icon Varsayılan simge + Delete Action Eylemi sil + Delete action Eylemi sil + Delete group Grubu Sil + Delete step Adımı sil + Delete the action "{name}"? “{name}” eylemi silinsin mi? + Delete the group "{name}"? “{name}” grubu silinsin mi? + Discard Vazgeç + Download İndir + Duplicate Action Eylemi çoğalt + Duplicate action Eylemi çoğalt + Duplicate group Grubu çoğalt + Duplicate step Adımı çoğalt + Edit Action Eylemi düzenle + Edit action Eylemi düzenle + Edit step settings Adım ayarlarını düzenle + Enable step Adımı etkinleştir + Expand all Tümünü genişlet + Export Action… Eylemi dışa aktar… + Finish or cancel action playback and the pending step first Önce eylem oynatımını ve bekleyen adımı tamamlayın veya iptal edin + Finishing step… Adım tamamlanıyor… + Generated layer effects Oluşturulan katman efektleri + Generated styles Oluşturulan stiller + Grey Gri + Harmonize layers Katmanları uyumlaştır + Hue and saturation Ton ve doygunluk + Import Actions… Eylemleri içe aktar… + Import actions or create a new action to get started. Başlamak için eylemleri içe aktarın veya yeni bir eylem oluşturun. + Insert Action Eylem ekle + Insert Conditional Koşul ekle + Insert Menu Item Menü öğesi ekle + Insert Path Yol ekle + Insert Stop Durdurma Ekle + Insert… Ekle… + Keyboard shortcut Klavye kısayolu + Lavender Lavanta + Layer effects Katman efektleri + Light blue Açık Mavi + List size Liste boyutu + Magic wand Sihirli değnek + Merge layers Katmanları Birleştir + Move step down Adımı aşağı taşı + Move step up Adımı yukarı taşı + New group Yeni Grup + No steps yet Henüz adım yok + Object selection Nesne seçimi + Play Action Eylemi oynat + Play Actions Eylemleri oynat + Play action Eylemi oynat + Play from selected step Seçili adımdan oynat + Playback Options… Oynatım Seçenekleri… + Play… Oynat… + Preview on hover Üzerine gelindiğinde önizle + Record Kaydı başlat + Recording Kaydediliyor + Remove background Arka Planı Kaldır + Save Action Eylemi kaydet + Search actions Eylemlerde ara + Select an existing action step to play Oynatmak için mevcut bir eylem adımı seçin + Select subject Konuyu Seç + Show grid Izgarayı Göster + Show step settings Adım ayarlarını göster + Smart removal Akıllı kaldırma + Sort & View options Sıralama ve Görünüm seçenekleri + Start recording to build your action Eyleminizi oluşturmak için kaydı başlatın + Steps Adımlar + Stored action step Saklanan eylem adımı + Stored settings Saklanan ayarlar + Teal Mavimsi yeşil + Text Metin + Text colour Metin rengi + The draft steps changed. Cancel this dialog and reopen the step. Taslak adımları değişti. Bu iletişim kutusunu iptal edip adımı yeniden açın. + The dragged action changed; drag it again Sürüklenen eylem değişti; yeniden sürükleyin + The editor response does not belong to this draft Düzenleyici yanıtı bu taslağa ait değil + There are unsaved changes. Do you want to save or discard them? Kaydedilmemiş değişiklikler var. Kaydetmek mi, vazgeçmek mi istiyorsunuz? + This editor page is closed Bu düzenleyici sayfası kapalı + This step dialog belongs to another editor Bu adım iletişim kutusu başka bir düzenleyiciye ait + Wait for the current action step to finish Geçerli eylem adımının bitmesini bekleyin + You haven't created any actions Henüz hiçbir eylem oluşturmadınız + Yours Size ait + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Gruplanmamış eylemler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 68128b85843..37854270398 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4231,3 +4231,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Колір контрольної точки Shapes can't use patterns yet Фігури поки не можуть використовувати візерунки Edit Brush… Редагувати пензель… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Дію збережено, але не вдалося зберегти налаштування підтвердження: {error} + Add a brief description for this action… Додайте короткий опис цієї дії… + Allow Tool Recording Дозволити запис інструментів + Answer the open Actions editor question first Спочатку дайте відповідь на відкрите запитання в редакторі дій + Apply to background Застосувати до тла + Apply to subject Застосувати до об’єкта + Back Назад + Black and white Чорно-біле + Cancel and continue editing Скасувати й продовжити редагування + Choose color Вибрати колір + Choose icon Вибрати піктограму + Classic Actions Класичні дії + Clear All Actions Очистити всі дії + Clear search Очистити пошук + Click action Клацання + Close the open Actions editor first Спочатку закрийте відкритий редактор дій + Collapse all Згорнути все + Colour lookup tables Таблиці перетворення кольорів + Confirm or cancel the open action dialog first Спочатку підтвердьте або скасуйте відкритий діалог дії + Control Ctrl + Default icon Типова піктограма + Delete Action Видалити дію + Delete action Видалити дію + Delete group Видалити групу + Delete step Видалити крок + Delete the action "{name}"? Видалити дію «{name}»? + Delete the group "{name}"? Видалити групу «{name}»? + Discard Відкинути + Download Завантажити + Duplicate Action Дублювати дію + Duplicate action Дублювати дію + Duplicate group Дублювати групу + Duplicate step Дублювати крок + Edit Action Редагувати дію + Edit action Редагувати дію + Edit step settings Редагувати налаштування кроку + Enable step Увімкнути крок + Expand all Розгорнути все + Export Action… Експортувати дію… + Finish or cancel action playback and the pending step first Спочатку завершіть або скасуйте відтворення дії та крок, що очікує + Finishing step… Завершення кроку… + Generated layer effects Згенеровані ефекти шару + Generated styles Згенеровані стилі + Grey Сірий + Harmonize layers Узгодити шари + Hue and saturation Відтінок і насиченість + Import Actions… Імпортувати дії… + Import actions or create a new action to get started. Імпортуйте дії або створіть нову дію, щоб розпочати. + Insert Action Вставити дію + Insert Conditional Вставити умову + Insert Menu Item Вставити пункт меню + Insert Path Вставити контур + Insert Stop Вставити зупинку + Insert… Вставити… + Keyboard shortcut Сполучення клавіш + Lavender Лавандовий + Layer effects Ефекти шару + Light blue Світло-синій + List size Розмір списку + Magic wand Чарівна паличка + Merge layers Об'єднати шари + Move step down Перемістити крок униз + Move step up Перемістити крок угору + New group Нова група + No steps yet Кроків ще немає + Object selection Вибір об’єкта + On Увімк. + Play Action Відтворити дію + Play Actions Відтворити дії + Play action Відтворити дію + Play from selected step Відтворити з вибраного кроку + Playback Options… Параметри відтворення… + Play… Відтворити… + Preview on hover Попередній перегляд при наведенні + Record Записати + Recording Запис + Remove background Видалити фон + Save Action Зберегти дію + Search actions Пошук дій + Select an existing action step to play Виберіть наявний крок дії для відтворення + Select subject Виділити об'єкт + Show grid Показати сітку + Show step settings Показати налаштування кроку + Smart removal Розумне видалення + Sort & View options Параметри сортування та вигляду + Start recording to build your action Почніть запис, щоб створити дію + Steps Кроки + Stored action step Збережений крок дії + Stored settings Збережені налаштування + Teal Синьо-зелений + Text Текст + Text colour Колір тексту + The draft steps changed. Cancel this dialog and reopen the step. Кроки чернетки змінилися. Скасуйте цей діалог і знову відкрийте крок. + The dragged action changed; drag it again Перетягувана дія змінилася; перетягніть її знову + The editor response does not belong to this draft Відповідь редактора не належить цій чернетці + There are unsaved changes. Do you want to save or discard them? Є незбережені зміни. Зберегти їх чи відкинути? + This editor page is closed Цю сторінку редактора закрито + This step dialog belongs to another editor Цей діалог кроку належить іншому редактору + Wait for the current action step to finish Дочекайтеся завершення поточного кроку дії + You haven't created any actions Ви ще не створили жодної дії + Yours Ваші + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Незгруповані дії diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d868ab099a9..09b87efeadb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4027,3 +4027,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Stop Color Màu điểm dừng Shapes can't use patterns yet Hình chưa thể dùng mẫu tô Edit Brush… Chỉnh sửa cọ… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} Đã lưu thao tác nhưng không thể lưu tùy chọn xác nhận: {error} + Add a brief description for this action… Thêm mô tả ngắn cho thao tác này… + Allow Tool Recording Cho phép ghi công cụ + Answer the open Actions editor question first Trước tiên hãy trả lời câu hỏi đang mở trong trình chỉnh sửa thao tác + Apply to background Áp dụng cho nền + Apply to subject Áp dụng cho chủ thể + Back Quay lại + Black and white Đen trắng + Cancel and continue editing Hủy và tiếp tục chỉnh sửa + Choose color Chọn màu + Choose icon Chọn biểu tượng + Classic Actions Thao tác cổ điển + Clear All Actions Xóa tất cả thao tác + Clear search Xóa tìm kiếm + Click action Thao tác nhấp + Close the open Actions editor first Trước tiên hãy đóng trình chỉnh sửa thao tác đang mở + Collapse all Thu gọn tất cả + Colour lookup tables Bảng tra màu + Confirm or cancel the open action dialog first Trước tiên hãy xác nhận hoặc hủy hộp thoại thao tác đang mở + Control Ctrl + Default icon Biểu tượng mặc định + Delete Action Xóa thao tác + Delete action Xóa thao tác + Delete group Xóa nhóm + Delete step Xóa bước + Delete the action "{name}"? Xóa thao tác “{name}”? + Delete the group "{name}"? Xóa nhóm “{name}”? + Discard Bỏ + Download Tải xuống + Duplicate Action Nhân bản thao tác + Duplicate action Nhân bản thao tác + Duplicate group Nhân bản nhóm + Duplicate step Nhân bản bước + Edit Action Chỉnh sửa thao tác + Edit action Chỉnh sửa thao tác + Edit step settings Chỉnh sửa cài đặt bước + Enable step Bật bước + Expand all Mở rộng tất cả + Export Action… Xuất thao tác… + Finish or cancel action playback and the pending step first Trước tiên hãy hoàn tất hoặc hủy việc phát thao tác và bước đang chờ + Finishing step… Đang hoàn tất bước… + Generated layer effects Hiệu ứng lớp được tạo + Generated styles Kiểu được tạo + Grey Xám + Harmonize layers Hài hòa các lớp + Hue and saturation Sắc độ và độ bão hòa + Import Actions… Nhập thao tác… + Import actions or create a new action to get started. Nhập thao tác hoặc tạo thao tác mới để bắt đầu. + Insert Action Chèn thao tác + Insert Conditional Chèn điều kiện + Insert Menu Item Chèn mục menu + Insert Path Chèn đường dẫn + Insert Stop Chèn điểm dừng + Insert… Chèn… + Keyboard shortcut Phím tắt + Lavender Màu oải hương + Layer effects Hiệu ứng lớp + Light blue Xanh lam nhạt + List size Kích thước danh sách + Magic wand Đũa thần + Merge layers Gộp lớp + Move step down Di chuyển bước xuống + Move step up Di chuyển bước lên + New group Nhóm mới + No steps yet Chưa có bước nào + Object selection Chọn đối tượng + On Bật + Play Action Phát thao tác + Play Actions Phát các thao tác + Play action Phát thao tác + Play from selected step Phát từ bước đã chọn + Playback Options… Tùy chọn phát… + Play… Phát… + Preview on hover Xem trước khi di chuột qua + Record Ghi + Recording Đang ghi + Remove background Gỡ bỏ nền + Save Action Lưu thao tác + Search actions Tìm thao tác + Select an existing action step to play Chọn một bước thao tác hiện có để phát + Select subject Chọn chủ thể + Show grid Hiện lưới + Show step settings Hiển thị cài đặt bước + Smart removal Xóa thông minh + Sort & View options Tùy chọn sắp xếp và hiển thị + Start recording to build your action Bắt đầu ghi để tạo thao tác của bạn + Steps Các bước + Stored action step Bước thao tác đã lưu + Stored settings Cài đặt đã lưu + Teal Xanh mòng két + Text Văn bản + Text colour Màu văn bản + The draft steps changed. Cancel this dialog and reopen the step. Các bước trong bản nháp đã thay đổi. Hãy hủy hộp thoại này rồi mở lại bước. + The dragged action changed; drag it again Thao tác được kéo đã thay đổi; hãy kéo lại + The editor response does not belong to this draft Phản hồi của trình chỉnh sửa không thuộc bản nháp này + There are unsaved changes. Do you want to save or discard them? Có thay đổi chưa được lưu. Bạn muốn lưu hay bỏ chúng? + This editor page is closed Trang chỉnh sửa này đã đóng + This step dialog belongs to another editor Hộp thoại bước này thuộc trình chỉnh sửa khác + Wait for the current action step to finish Đợi bước thao tác hiện tại hoàn tất + You haven't created any actions Bạn chưa tạo thao tác nào + Yours Của bạn + +# Actions reserved category caption (10945/10946) + Ungrouped Actions Thao tác chưa được nhóm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9b554acfa5..53b157278ad 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3205,3 +3205,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color 纯色 Tint Color 色调颜色 Edit Brush… 编辑画笔… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} 动作已保存,但无法保存确认偏好设置:{error} + Add a brief description for this action… 为此动作添加简短描述… + Allow Tool Recording 允许录制工具操作 + Answer the open Actions editor question first 请先回答动作编辑器中打开的问题 + Apply to background 应用于背景 + Apply to subject 应用于主体 + Back 返回 + Black and white 黑白 + Cancel and continue editing 取消并继续编辑 + Choose color 选择颜色 + Choose icon 选择图标 + Classic Actions 经典动作 + Clear All Actions 清除所有动作 + Clear search 清除搜索 + Click action 点击操作 + Close the open Actions editor first 请先关闭已打开的动作编辑器 + Collapse all 全部折叠 + Colour lookup tables 颜色查找表 + Confirm or cancel the open action dialog first 请先确认或取消已打开的动作对话框 + Control Ctrl + Default icon 默认图标 + Delete Action 删除动作 + Delete action 删除动作 + Delete group 删除组 + Delete step 删除步骤 + Delete the action "{name}"? 删除动作“{name}”? + Delete the group "{name}"? 删除组“{name}”? + Discard 放弃 + Download 下载 + Duplicate Action 复制动作 + Duplicate action 复制动作 + Duplicate group 复制组 + Duplicate step 复制步骤 + Edit Action 编辑动作 + Edit action 编辑动作 + Edit step settings 编辑步骤设置 + Enable step 启用步骤 + Expand all 全部展开 + Export Action… 导出动作… + Finish or cancel action playback and the pending step first 请先完成或取消动作播放及待处理步骤 + Finishing step… 正在完成步骤… + Generated layer effects 生成的图层效果 + Generated styles 生成的样式 + Grey 灰色 + Harmonize layers 协调图层 + Hue and saturation 色相与饱和度 + Import Actions… 导入动作… + Import actions or create a new action to get started. 导入动作或创建新动作,即可开始使用。 + Insert Action 插入动作 + Insert Conditional 插入条件 + Insert Menu Item 插入菜单项 + Insert Path 插入路径 + Insert Stop 插入停止 + Insert… 插入… + Keyboard shortcut 键盘快捷键 + Lavender 薰衣草色 + Layer effects 图层效果 + Light blue 浅蓝色 + List size 列表大小 + Magic wand 魔棒 + Merge layers 合并图层 + Move step down 下移步骤 + Move step up 上移步骤 + New group 新建组 + No steps yet 尚无步骤 + Object selection 对象选择 + On 开启 + Play Action 播放动作 + Play Actions 播放动作 + Play action 播放动作 + Play from selected step 从所选步骤播放 + Playback Options… 播放选项… + Play… 播放… + Preview on hover 悬停时预览 + Record 录制 + Recording 正在录制 + Remove background 移除背景 + Save Action 保存动作 + Search actions 搜索动作 + Select an existing action step to play 选择要播放的现有动作步骤 + Select subject 选择主体 + Show grid 显示网格 + Show step settings 显示步骤设置 + Smart removal 智能移除 + Sort & View options 排序与视图选项 + Start recording to build your action 开始录制以创建动作 + Steps 步骤 + Stored action step 已保存的动作步骤 + Stored settings 已保存的设置 + Teal 青绿色 + Text 文本 + Text colour 文本颜色 + The draft steps changed. Cancel this dialog and reopen the step. 草稿步骤已更改。请取消此对话框并重新打开该步骤。 + The dragged action changed; drag it again 拖动的动作已更改;请重新拖动 + The editor response does not belong to this draft 编辑器响应不属于此草稿 + There are unsaved changes. Do you want to save or discard them? 有未保存的更改。要保存还是放弃? + This editor page is closed 此编辑器页面已关闭 + This step dialog belongs to another editor 此步骤对话框属于另一个编辑器 + Wait for the current action step to finish 请等待当前动作步骤完成 + You haven't created any actions 您尚未创建任何动作 + Yours 我的动作 + +# Actions reserved category caption (10945/10946) + Ungrouped Actions 未分组动作 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9379418b9e5..6026f454d40 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3161,3 +3161,109 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Solid Color 純色 Tint Color 色調顏色 Edit Brush… 編輯筆刷… + +# Actions list and editor (10945/10946) + Action saved, but confirmation preference could not be saved: {error} 動作已儲存,但無法儲存確認偏好設定:{error} + Add a brief description for this action… 為此動作新增簡短說明… + Allow Tool Recording 允許錄製工具操作 + Answer the open Actions editor question first 請先回答動作編輯器中開啟的問題 + Apply to background 套用至背景 + Apply to subject 套用至主體 + Back 返回 + Black and white 黑白 + Cancel and continue editing 取消並繼續編輯 + Choose color 選擇顏色 + Choose icon 選擇圖示 + Classic Actions 傳統動作 + Clear All Actions 清除所有動作 + Clear search 清除搜尋 + Click action 點擊操作 + Close the open Actions editor first 請先關閉已開啟的動作編輯器 + Collapse all 全部摺疊 + Colour lookup tables 色彩查詢表 + Confirm or cancel the open action dialog first 請先確認或取消已開啟的動作對話框 + Control Ctrl + Default icon 預設圖示 + Delete Action 刪除動作 + Delete action 刪除動作 + Delete group 刪除群組 + Delete step 刪除步驟 + Delete the action "{name}"? 刪除動作「{name}」? + Delete the group "{name}"? 刪除群組「{name}」? + Discard 捨棄 + Download 下載 + Duplicate Action 複製動作 + Duplicate action 複製動作 + Duplicate group 複製群組 + Duplicate step 複製步驟 + Edit Action 編輯動作 + Edit action 編輯動作 + Edit step settings 編輯步驟設定 + Enable step 啟用步驟 + Expand all 全部展開 + Export Action… 匯出動作… + Finish or cancel action playback and the pending step first 請先完成或取消動作播放及待處理步驟 + Finishing step… 正在完成步驟… + Generated layer effects 產生的圖層效果 + Generated styles 產生的樣式 + Grey 灰色 + Harmonize layers 協調圖層 + Hue and saturation 色相與飽和度 + Import Actions… 匯入動作… + Import actions or create a new action to get started. 匯入動作或建立新動作,即可開始使用。 + Insert Action 插入動作 + Insert Conditional 插入條件 + Insert Menu Item 插入選單項目 + Insert Path 插入路徑 + Insert Stop 插入停止 + Insert… 插入… + Keyboard shortcut 鍵盤快速鍵 + Lavender 薰衣草色 + Layer effects 圖層效果 + Light blue 淺藍色 + List size 清單大小 + Magic wand 魔術棒 + Merge layers 合併圖層 + Move step down 下移步驟 + Move step up 上移步驟 + New group 新增群組 + No steps yet 尚無步驟 + Object selection 物件選取 + On 開 + Play Action 播放動作 + Play Actions 播放動作 + Play action 播放動作 + Play from selected step 從所選步驟播放 + Playback Options… 播放選項… + Play… 播放… + Preview on hover 暫留時預覽 + Record 錄製 + Recording 正在錄製 + Remove background 移除背景 + Save Action 儲存動作 + Search actions 搜尋動作 + Select an existing action step to play 選取要播放的現有動作步驟 + Select subject 選取主體 + Show grid 顯示格點 + Show step settings 顯示步驟設定 + Smart removal 智慧移除 + Sort & View options 排序與檢視選項 + Start recording to build your action 開始錄製以建立動作 + Steps 步驟 + Stored action step 已儲存的動作步驟 + Stored settings 已儲存的設定 + Teal 藍綠色 + Text 文字 + Text colour 文字顏色 + The draft steps changed. Cancel this dialog and reopen the step. 草稿步驟已變更。請取消此對話框並重新開啟該步驟。 + The dragged action changed; drag it again 拖曳的動作已變更;請重新拖曳 + The editor response does not belong to this draft 編輯器回應不屬於此草稿 + There are unsaved changes. Do you want to save or discard them? 有未儲存的變更。要儲存還是捨棄? + This editor page is closed 此編輯器頁面已關閉 + This step dialog belongs to another editor 此步驟對話框屬於另一個編輯器 + Wait for the current action step to finish 請等候目前的動作步驟完成 + You haven't created any actions 您尚未建立任何動作 + Yours 我的動作 + +# Actions reserved category caption (10945/10946) + Ungrouped Actions 未分組動作 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index d261e762cda..aa8753ba2fc 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -1,6 +1,36 @@ //! Icons embedded and tinted at runtime; sources and licences are listed in `ATTRIBUTION.md`. pub static ICONS: &[(&str, &[u8])] = &[ + ("action-editor-adjust", include_bytes!("../../../assets/icons/action-editor/adjust.svg")), + ("action-editor-black-white", include_bytes!("../../../assets/icons/action-editor/black-white.svg")), + ("action-editor-blur", include_bytes!("../../../assets/icons/action-editor/blur.svg")), + ("action-editor-brush", include_bytes!("../../../assets/icons/action-editor/brush.svg")), + ("action-editor-burn", include_bytes!("../../../assets/icons/action-editor/burn.svg")), + ("action-editor-clarity", include_bytes!("../../../assets/icons/action-editor/clarity.svg")), + ("action-editor-click", include_bytes!("../../../assets/icons/action-editor/click.svg")), + ("action-editor-crop", include_bytes!("../../../assets/icons/action-editor/crop.svg")), + ("action-editor-dodge", include_bytes!("../../../assets/icons/action-editor/dodge.svg")), + ("action-editor-download", include_bytes!("../../../assets/icons/action-editor/download.svg")), + ("action-editor-erase", include_bytes!("../../../assets/icons/action-editor/erase.svg")), + ("action-editor-exposure", include_bytes!("../../../assets/icons/action-editor/exposure.svg")), + ("action-editor-generate", include_bytes!("../../../assets/icons/action-editor/generate.svg")), + ("action-editor-generative-effects", include_bytes!("../../../assets/icons/action-editor/generative-effects.svg")), + ("action-editor-grid", include_bytes!("../../../assets/icons/action-editor/grid.svg")), + ("action-editor-harmonize", include_bytes!("../../../assets/icons/action-editor/harmonize.svg")), + ("action-editor-hue", include_bytes!("../../../assets/icons/action-editor/hue.svg")), + ("action-editor-layer-effects", include_bytes!("../../../assets/icons/action-editor/layer-effects.svg")), + ("action-editor-layers", include_bytes!("../../../assets/icons/action-editor/layers.svg")), + ("action-editor-lut", include_bytes!("../../../assets/icons/action-editor/lut.svg")), + ("action-editor-merge", include_bytes!("../../../assets/icons/action-editor/merge.svg")), + ("action-editor-object-select", include_bytes!("../../../assets/icons/action-editor/object-select.svg")), + ("action-editor-remove-background", include_bytes!("../../../assets/icons/action-editor/remove-background.svg")), + ("action-editor-sharpen", include_bytes!("../../../assets/icons/action-editor/sharpen.svg")), + ("action-editor-smart-remove", include_bytes!("../../../assets/icons/action-editor/smart-remove.svg")), + ("action-editor-styles", include_bytes!("../../../assets/icons/action-editor/styles.svg")), + ("action-editor-subject", include_bytes!("../../../assets/icons/action-editor/subject.svg")), + ("action-editor-text-color", include_bytes!("../../../assets/icons/action-editor/text-color.svg")), + ("action-editor-text", include_bytes!("../../../assets/icons/action-editor/text.svg")), + ("action-editor-wand", include_bytes!("../../../assets/icons/action-editor/wand.svg")), ("adjustment-layer", include_bytes!("../../../assets/icons/adjustment-layer.svg")), ("align-center", include_bytes!("../../../assets/icons/align-center.svg")), ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0a42a03527e..57d5b6d18e4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -223,6 +223,10 @@ pub fn rail_button(ui: &mut egui::Ui, name: &str, box_size: f32, on: bool, toolt resp.on_hover_text(tl!(tooltip)) } +#[cfg(test)] +#[path = "panels/actions/editor_glyph_tests.rs"] +mod action_editor_glyph_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5a9841ef601..b09b5cf3431 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -544,6 +544,8 @@ pub struct PhotocraftApp { /// Action playbacks paused at a step dialog, by the token in that dialog's fields /// ([`actions::RESUME`]): what to play next and the automation gate it paused under. pub(crate) paused_playbacks: actions::PausedPlaybacks, + /// Runtime Actions editor buffers and owned UI continuations; never part of workspace JSON. + pub(crate) actions_runtime: actions::Runtime, /// Levels/Curves histogram cache: (document, adjustment layer, revision it is valid for). pub(crate) tone_hist: Option<(DocId, photocraft_doc::LayerId, u64, std::sync::Arc)>, /// Histogram panel: the document's histogram (`histogram_panel`). diff --git a/crates/ui-egui/src/menus/enable_rules.rs b/crates/ui-egui/src/menus/enable_rules.rs index 6abf64fd1d0..7011b05f23a 100644 --- a/crates/ui-egui/src/menus/enable_rules.rs +++ b/crates/ui-egui/src/menus/enable_rules.rs @@ -337,12 +337,12 @@ mod tests { for sel in selections { for copied in [false, true] { for (cmd, must_change) in commands { - let app = crate::PhotocraftApp::new(world(sel, copied), crate::Services::default()); + let mut app = crate::PhotocraftApp::new(world(sel, copied), crate::Services::default()); let enabled = crate::menus::is_enabled(&app, cmd); - let mut s = app.session; - let before = fingerprint(&s); + let s = &mut app.session; + let before = fingerprint(s); let r = s.execute(cmd, json!({})); - let changed = fingerprint(&s) != before; + let changed = fingerprint(s) != before; let what = format!( "{cmd} for {sel:?} (copied style: {copied}), enabled={enabled}, result={:?}", r.as_ref().map(|_| ()).map_err(|e| e.to_string()) diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index c932ca092c8..e4826a0d093 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -3,6 +3,9 @@ use super::items::MenuItem; use super::{DOCUMENT_ITEM, PhotocraftApp, Value}; pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { + if matches!(id, "actions.layout.modern" | "actions.layout.classic") { + return !crate::actions::editor_busy(app); + } if id == "layer.selectUnderCursor" { return app.session.active().is_some() && app.drag.is_none() && app.brush_resize.is_none(); } @@ -119,6 +122,9 @@ const MODE_CHECKS: [&str; 11] = ["rgb", "grayscale", "cmyk", "lab", "multichanne /// Is a UI-level panel toggle currently on (for checkmarks)? pub(super) fn checked(app: &PhotocraftApp, id: &str) -> Option { + if let Some(checked) = crate::actions::layout_checked(app, id) { + return Some(checked); + } if let Some(checked) = crate::panel_docking::checked(app, id) { return Some(checked); } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 58a2a77eab5..7ea0f870411 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -15,6 +15,39 @@ use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; use crate::icons; use crate::theme::Tokens; +mod editor; +mod editor_glyphs; +mod editor_picker; +mod modern; + +pub(crate) use modern::Runtime; +pub(crate) use modern::discarded; +pub(crate) use modern::{delete_body, delete_cancel, delete_confirm, guard_exit, guard_navigation, modals, owns_delete, shutdown, tick}; +pub(crate) use modern::{editor_busy, inspect, layout_checked, layout_command, persist, restore}; + +pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if app.ui.actions_layout == crate::state::ActionsLayout::Classic { + classic_panel(app, ui); + } else { + modern::panel(app, ui); + } +} + +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if app.ui.actions_layout == crate::state::ActionsLayout::Classic { + classic_panel_menu(app, ui); + ui.separator(); + modern::layout_menu(app, ui); + } else { + modern::panel_menu(app, ui); + } +} + +/// Translate only the reserved category's display caption, never a stored group identity. +fn set_label(set: &str) -> String { + if set == photocraft_engine::atn_cmds::UNGROUPED_SET { tl!("Ungrouped Actions").to_string() } else { set.to_string() } +} + /// Which row is selected and which rows are expanded. The action list itself is /// [`photocraft_engine::Session::actions`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -37,6 +70,8 @@ pub struct ActionsUi { /// The action whose name is being edited in place (double-click on its name). #[serde(skip)] renaming: Option, + #[serde(skip)] + search: String, } impl ActionsUi { @@ -86,7 +121,14 @@ fn selected_set(app: &PhotocraftApp) -> Option<&str> { /// The actions of the selected set, in list order (empty when no set is selected, or it is empty). fn selected_set_actions(app: &PhotocraftApp) -> Vec { let Some(set) = selected_set(app) else { return Vec::new() }; - app.session.actions.list.iter().enumerate().filter(|(_, a)| a.set.as_deref() == Some(set)).map(|(i, _)| i).collect() + app.session + .actions + .list + .iter() + .enumerate() + .filter(|(_, a)| photocraft_engine::atn_cmds::logical_set_name(a.set.as_deref()) == set) + .map(|(i, _)| i) + .collect() } /// What Play plays: the selected action; with a set folder selected, every action of that set in @@ -312,7 +354,16 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ let mut ran = 0u64; let mut failed = None; let mut paused = None; + let mut yielded = false; for (i, (id, p)) in action.enabled_steps().skip_while(|(i, _)| *i < frame.from) { + if app.actions_runtime.quantum == Some(0) { + app.actions_runtime.yielded = Some(frame.after(i)); + yielded = true; + break; + } + if let Some(left) = &mut app.actions_runtime.quantum { + *left = left.saturating_sub(1); + } if !actions_cmds::consume_playback_step(&mut app.session) { failed = Some(json!({"step": i, "id": id, "error": format!("action playback exceeded the {}-step limit", actions_cmds::MAX_PLAYBACK_STEPS)})); break; @@ -398,6 +449,12 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ paused = Some(inner.clone()); break; } + if value.get("yielded").and_then(Value::as_bool) == Some(true) { + app.actions_runtime.parents.push(frame.after(i + 1)); + ran = ran.saturating_add(value.get("ran").and_then(Value::as_u64).unwrap_or(0)); + yielded = true; + break; + } ran += 1; } Err(error) => { @@ -410,6 +467,7 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ Ok(match (failed, paused) { (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), + (None, None) if yielded => json!({"action": action.name, "ran": ran, "yielded":true}), (None, None) => json!({"action": action.name, "ran": ran}), }) } @@ -474,7 +532,8 @@ fn begin_recording(app: &mut PhotocraftApp, append: bool) { } app.ui.actions.expanded[i] = true; // Its set too, in case its folder is collapsed. - if let Some(set) = app.session.actions.list.get(i).and_then(|a| a.set.as_ref()) { + if let Some(action) = app.session.actions.list.get(i) { + let set = photocraft_engine::atn_cmds::logical_set_name(action.set.as_deref()); app.ui.actions.collapsed_sets.retain(|c| c != set); } } @@ -705,7 +764,8 @@ fn load_dialog(app: &mut PhotocraftApp) -> Result { fn save_dialog(app: &mut PhotocraftApp, set: Option) -> Result { let selected_set = app.ui.actions.selected.is_none().then(|| app.ui.actions.selected_set.clone()).flatten(); let selected_set = selected_set.filter(|set| photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(set)); - let set = set.or(selected_set).or_else(|| selected_action(app).and_then(|a| a.set.clone())); + let set = + set.or(selected_set).or_else(|| selected_action(app).map(|action| photocraft_engine::atn_cmds::logical_set_name(action.set.as_deref()).to_string())); let params = match &set { Some(set) => json!({"set": set, "skipUnsupported": true}), None => json!({"skipUnsupported": true}), @@ -852,10 +912,13 @@ pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), Strin /// A failed step ends its owner once, without rolling back document work. pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map) { - if let Some(paused) = fields.get(RESUME).and_then(|token| app.paused_playbacks.take(token)) - && let Some(record) = paused.record - { - record.finish(&mut app.session); + if let Some(paused) = fields.get(RESUME).and_then(|token| app.paused_playbacks.take(token)) { + if paused.incremental { + modern::cancel(app); + } + if let Some(record) = paused.record { + record.finish(&mut app.session); + } } } @@ -871,6 +934,7 @@ pub(crate) struct PausedPlayback { then: Vec, ran: u64, used: u64, + incremental: bool, } #[derive(Debug, Default)] @@ -883,7 +947,8 @@ impl PausedPlaybacks { fn pause(&mut self, frame: Frame, dialog: u64, gate: Option, used: u64) -> Result { let token = self.last.checked_add(1).ok_or("action step dialog identities exhausted")?; self.last = token; - self.paused.insert(token, PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used }); + self.paused + .insert(token, PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used, incremental: false }); Ok(token) } @@ -972,6 +1037,10 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) } let saved = app.session.authorize; app.session.authorize = paused.gate.or(saved); + if paused.incremental { + app.session.authorize = saved; + return modern::resume_tick(app, paused); + } let preserve_tail = paused.record.as_ref().is_some_and(|owner| owner.is_preview()); let source = paused.frame.action.clone(); let mut played = run_owned(app, paused.frame, paused.parents, paused.record, paused.ran.saturating_add(1), paused.used, preserve_tail); @@ -1020,7 +1089,7 @@ pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) } /// The Actions entries of the panel group's menu ("…"). -pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { +fn classic_panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut act: Option<&str> = None; let recording = app.session.actions.recording.is_some(); let any = !app.session.actions.list.is_empty(); @@ -1139,7 +1208,9 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { // Dropped among another set's actions: the action joins that set (the row above wins). let list = &app.session.actions.list; let neighbour = to.checked_sub(1).and_then(|j| list.get(j)).or_else(|| list.get(to + 1)).and_then(|a| a.set.clone()); - if list.get(to).is_some_and(|a| a.set != neighbour) { + if list.get(to).is_some_and(|a| { + photocraft_engine::atn_cmds::logical_set_name(a.set.as_deref()) != photocraft_engine::atn_cmds::logical_set_name(neighbour.as_deref()) + }) { let _ = app.run("actions.assignSet", json!({"action": to, "set": neighbour})); } } @@ -1214,17 +1285,21 @@ struct Row { /// The panel's order: actions outside any set first, then each set folder (in order of first /// appearance) with its actions. Indices are into the engine's flat list. fn display_order(rows: &[Row]) -> Vec { - let mut order: Vec = rows.iter().enumerate().filter(|(_, r)| r.set.is_none()).map(|(i, _)| i).collect(); + let mut order: Vec = rows + .iter() + .enumerate() + .filter(|(_, row)| photocraft_engine::atn_cmds::logical_set_name(row.set.as_deref()) == photocraft_engine::atn_cmds::UNGROUPED_SET) + .map(|(i, _)| i) + .collect(); let mut sets: Vec<&str> = Vec::new(); for r in rows { - if let Some(set) = r.set.as_deref() - && !sets.contains(&set) - { + let set = photocraft_engine::atn_cmds::logical_set_name(r.set.as_deref()); + if set != photocraft_engine::atn_cmds::UNGROUPED_SET && !sets.contains(&set) { sets.push(set); } } for set in sets { - order.extend(rows.iter().enumerate().filter(|(_, r)| r.set.as_deref() == Some(set)).map(|(i, _)| i)); + order.extend(rows.iter().enumerate().filter(|(_, row)| photocraft_engine::atn_cmds::logical_set_name(row.set.as_deref()) == set).map(|(i, _)| i)); } order } @@ -1239,7 +1314,8 @@ struct SetHeaderResult { fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, selected: bool, tokens: &Tokens) -> SetHeaderResult { let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, set)); + let label = set_label(set); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &label)); if selected { ui.painter().rect_filled(rect, 0.0, tokens.row_selected); } else if response.hovered() { @@ -1259,7 +1335,7 @@ fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, se 12.0, tokens.icon, ); - ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, set, egui::FontId::proportional(12.0), tokens.text); + ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), tokens.text); let summary = crate::i18n::trn(crate::i18n::current(), count, "{n} action", "{n} actions"); ui.painter().text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, summary, egui::FontId::proportional(11.0), tokens.text_faint); // As on an action row, a click in the triangle's column collapses or expands and one on the @@ -1283,7 +1359,8 @@ fn draw_set_header(ui: &mut egui::Ui, set: &str, count: u64, collapsed: bool, se /// was chosen. fn draw_empty_sets(app: &PhotocraftApp, ui: &mut egui::Ui, rows: &[Row], t: &Tokens) -> (Option<(String, bool, bool)>, Option) { let (mut clicked, mut save) = (None, None); - for set in app.session.actions.sets.iter().filter(|set| !rows.iter().any(|r| r.set.as_ref() == Some(*set))) { + let sets = photocraft_engine::actions_edit_cmds::set_names(&app.session); + for set in sets.iter().filter(|set| !rows.iter().any(|row| photocraft_engine::atn_cmds::logical_set_name(row.set.as_deref()) == set.as_str())) { let selected = app.ui.actions.selected.is_none() && app.ui.actions.selected_set.as_ref() == Some(set); let collapsed = app.ui.actions.collapsed_sets.contains(set); let result = draw_set_header(ui, set, 0, collapsed, selected, t); @@ -1339,7 +1416,7 @@ fn action_context_menu(app: &mut PhotocraftApp, response: &egui::Response, index }); } -pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { +fn classic_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let recording = app.session.actions.recording; let rows: Vec = app @@ -1353,7 +1430,9 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { steps: visible_steps(app, i), disabled: a.disabled.clone(), dialogs: a.dialogs.clone(), - set: a.set.clone(), + set: (photocraft_engine::atn_cmds::logical_set_name(a.set.as_deref()) != photocraft_engine::atn_cmds::UNGROUPED_SET) + .then(|| a.set.clone()) + .flatten(), color: a.color.clone(), }) .collect(); diff --git a/crates/ui-egui/src/panels/actions/editor.rs b/crates/ui-egui/src/panels/actions/editor.rs new file mode 100644 index 00000000000..1434fcd667d --- /dev/null +++ b/crates/ui-egui/src/panels/actions/editor.rs @@ -0,0 +1,832 @@ +//! Runtime-only Actions editor page. Definitions change through owned backend commands only. +use std::collections::BTreeSet; + +use egui::{Color32, Id}; +use serde_json::{Map, Value, json}; + +use super::editor_picker::{self, Choice}; +use crate::{PhotocraftApp, filter_dialog, icons, theme::Tokens}; + +const CONFIRM_PREF: &str = "actions.editor.save.confirmation"; + +/// The shared playback adapter owns these conditions, including suspended step dialogs. +#[derive(Clone, Copy, Debug, Default)] +pub struct Activity { + pub playback_busy: bool, + pub finishing_step: bool, +} + +/// Parent navigation and playback effects. The page never substitutes committed-library playback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + Saved { source: Option }, + Closed { saved: bool, source: Option }, + CancelledLeave, + Preview { draft: u64, from: usize }, +} + +#[derive(Default)] +struct Buffer { + text: String, + acknowledged: String, +} + +impl Buffer { + fn dirty(&self) -> bool { + self.text != self.acknowledged + } + + fn refresh(&mut self, authoritative: &str) { + if !self.dirty() { + self.text = authoritative.to_string(); + } + self.acknowledged = authoritative.to_string(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Intent { + Save, + Leave, +} + +#[derive(Clone, Copy)] +enum Answer { + Save, + Discard, + Cancel, +} + +#[derive(Clone)] +enum Target { + Insert { at: usize, len: usize, previous: Option }, + Replace { at: usize, original: Value }, +} + +#[derive(Clone)] +struct StepEdit { + draft: u64, + target: Target, + command: String, + params: Map, +} + +/// Buffers and view state only: no Action/Session/document copy and no persistence attachment. +pub struct Page { + draft: u64, + name: Buffer, + description: Buffer, + shortcut: Buffer, + selected: Option, + expanded: BTreeSet, + prompt: Option, + dont_show_again: bool, + prompt_focused: bool, + editing: Option, + error: Option, + source: Option, + closed: bool, +} + +fn action_text<'a>(view: &'a Value, key: &str) -> &'a str { + view.get("action").and_then(|action| action.get(key)).and_then(Value::as_str).unwrap_or("") +} + +fn steps(view: &Value) -> &[Value] { + view.get("action").and_then(|action| action.get("steps")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) +} + +fn step_parts(step: &Value) -> Option<(&str, &Value)> { + let pair = step.as_array()?; + Some((pair.first()?.as_str()?, pair.get(1)?)) +} + +fn flagged(view: &Value, key: &str, step: usize) -> bool { + view.get("action") + .and_then(|action| action.get(key)) + .and_then(Value::as_array) + .is_some_and(|flags| flags.iter().any(|flag| flag.as_u64().and_then(|index| usize::try_from(index).ok()) == Some(step))) +} + +fn simple(kind: &filter_dialog::Kind) -> bool { + matches!( + kind, + filter_dialog::Kind::Range { .. } + | filter_dialog::Kind::Choice { .. } + | filter_dialog::Kind::Bool(_) + | filter_dialog::Kind::Int { .. } + | filter_dialog::Kind::Text + ) +} + +fn editable(command: &str) -> bool { + if command == photocraft_engine::atn_cmds::STOP_MESSAGE { + return true; + } + command != photocraft_engine::atn_cmds::PHOTOSHOP_STEP + && photocraft_engine::commands::find(command) + .is_some_and(|spec| filter_dialog::parse_spec(spec.params).iter().any(|param| !param.key.starts_with("__") && simple(¶m.kind))) +} + +fn summary(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Bool(value) => (if *value { tl!("On") } else { tl!("Off") }).to_string(), + Value::Number(value) => value.to_string(), + Value::Null => tl!("None").to_string(), + _ => tl!("Stored settings").to_string(), + } +} + +impl Page { + /// Open once at an actual New/Edit entry point; the backend allocates name and ownership. + pub fn open(app: &mut PhotocraftApp, params: Value) -> Result { + let view = app.run("actions.editor.open", params)?; + Self::attach(&view) + } + + /// Attach the actual page to a backend draft opened through the control channel. + pub(super) fn attach(view: &Value) -> Result { + let draft = view.get("draft").and_then(Value::as_u64).ok_or("The editor returned no draft identity")?; + let mut page = Self { + draft, + name: Buffer::default(), + description: Buffer::default(), + shortcut: Buffer::default(), + selected: None, + expanded: BTreeSet::new(), + prompt: None, + dont_show_again: false, + prompt_focused: false, + editing: None, + error: None, + source: None, + closed: false, + }; + page.accept(view)?; + Ok(page) + } + + pub fn draft(&self) -> u64 { + self.draft + } + + /// Parent inspection/close routing must retain this runtime page rather than serialize it. + pub fn has_modal(&self) -> bool { + self.prompt.is_some() || self.editing.is_some() + } + + fn accept(&mut self, view: &Value) -> Result<(), String> { + if view.get("draft").and_then(Value::as_u64) != Some(self.draft) + || view.get("action").and_then(|action| action.get("name")).and_then(Value::as_str).is_none() + || view.get("shortcut").and_then(Value::as_str).is_none() + || view.get("dirty").and_then(Value::as_bool).is_none() + || view.get("recording").and_then(Value::as_bool).is_none() + { + return Err(tl!("The editor response does not belong to this draft").into()); + } + self.name.refresh(action_text(view, "name")); + self.description.refresh(action_text(view, "description")); + self.shortcut.refresh(view.get("shortcut").and_then(Value::as_str).unwrap_or("")); + self.source = view.get("source").and_then(Value::as_u64).and_then(|index| usize::try_from(index).ok()); + let len = steps(view).len(); + self.selected = self.selected.filter(|index| *index < len); + self.expanded.retain(|index| *index < len); + Ok(()) + } + + fn call(&mut self, app: &mut PhotocraftApp, verb: &str, params: Value) -> Result { + if self.closed { + return Err(tl!("This editor page is closed").into()); + } + let mut params = params.as_object().cloned().unwrap_or_default(); + params.insert("draft".into(), json!(self.draft)); + let result = app.run(&format!("actions.editor.{verb}"), Value::Object(params)); + match result { + Ok(view) => { + if verb != "discard" { + if let Err(error) = self.accept(&view) { + self.error = Some(error.clone()); + return Err(error); + } + } + if verb != "get" { + self.error = None; + } + Ok(view) + } + Err(error) => { + self.error = Some(error.clone()); + Err(error) + } + } + } + + fn get(&mut self, app: &mut PhotocraftApp) -> Result { + match photocraft_engine::actions_cmds::editor::inspect(&app.session, self.draft).map_err(|error| error.to_string()) { + Ok(view) => { + self.accept(&view)?; + Ok(view) + } + Err(error) => { + self.error = Some(error.clone()); + Err(error) + } + } + } + + fn dirty(&self, view: &Value) -> bool { + view.get("dirty").and_then(Value::as_bool) == Some(true) || self.name.dirty() || self.description.dirty() || self.shortcut.dirty() + } + + fn stage(&mut self, app: &mut PhotocraftApp) -> Result { + let mut params = Map::new(); + for (key, buffer) in [("name", &self.name), ("description", &self.description), ("shortcut", &self.shortcut)] { + if buffer.dirty() { + params.insert(key.into(), json!(buffer.text)); + } + } + if params.is_empty() { self.get(app) } else { self.call(app, "update", Value::Object(params)) } + } + + fn suppressed(app: &PhotocraftApp) -> bool { + app.session.prefs().dialogs.get(CONFIRM_PREF).and_then(|value| value.get("skip")).and_then(Value::as_bool) == Some(true) + } + + fn ask_save(&mut self, app: &mut PhotocraftApp, activity: Activity) -> Result, String> { + let view = self.get(app)?; + if self.dirty(&view) { + self.prompt = Some(Intent::Save); + self.dont_show_again = false; + self.prompt_focused = false; + if Self::suppressed(app) { + return self.answer(app, Answer::Save, activity); + } + } + Ok(None) + } + + /// Parent stores its destination until Closed; CancelledLeave clears that pending navigation. + pub fn request_leave(&mut self, app: &mut PhotocraftApp, activity: Activity) -> Result, String> { + if activity.playback_busy || self.has_modal() { + return Err(tl!("Confirm or cancel the open action dialog first").into()); + } + let view = self.get(app)?; + if self.dirty(&view) || (activity.finishing_step && view.get("recording").and_then(Value::as_bool) == Some(true)) { + self.prompt = Some(Intent::Leave); + self.dont_show_again = false; + self.prompt_focused = false; + if Self::suppressed(app) { + return self.answer(app, Answer::Save, activity); + } + Ok(None) + } else { + self.call(app, "discard", json!({}))?; + self.closed = true; + Ok(Some(Event::Closed { saved: false, source: self.source })) + } + } + + fn answer(&mut self, app: &mut PhotocraftApp, answer: Answer, activity: Activity) -> Result, String> { + let intent = self.prompt.ok_or("There is no Save Action prompt")?; + if matches!(answer, Answer::Cancel) { + self.prompt = None; + return Ok((intent == Intent::Leave).then_some(Event::CancelledLeave)); + } + if activity.playback_busy || (activity.finishing_step && matches!(answer, Answer::Save)) { + let error = tl!("Wait for the current action step to finish").to_string(); + self.error = Some(error.clone()); + return Err(error); + } + if matches!(answer, Answer::Save) { + self.stage(app)?; + self.call(app, "save", json!({}))?; + if self.dont_show_again { + let previous = app.session.prefs().dialogs.get(CONFIRM_PREF).cloned(); + app.session.edit_prefs(|prefs| { + prefs.dialogs.insert(CONFIRM_PREF.into(), json!({"skip": true})); + }); + if let Err(error) = crate::prefs_ui::save_preferences(app) { + app.session.edit_prefs(|prefs| { + if let Some(previous) = previous { + prefs.dialogs.insert(CONFIRM_PREF.into(), previous); + } else { + prefs.dialogs.remove(CONFIRM_PREF); + } + }); + let error = crate::i18n::fmt(tl!("Action saved, but confirmation preference could not be saved: {error}"), &[("error", &error)]); + self.error = Some(error.clone()); + return Err(error); // Keep prompt and owned draft for an explicit retry or cancel. + } + } + if intent == Intent::Save { + self.prompt = None; + return Ok(Some(Event::Saved { source: self.source })); + } + } + self.call(app, "discard", json!({}))?; + self.prompt = None; + self.closed = true; + Ok(Some(Event::Closed { saved: matches!(answer, Answer::Save), source: self.source })) + } + + fn preview(&mut self, app: &mut PhotocraftApp, from: usize) -> Result { + let view = self.stage(app)?; + if from >= steps(&view).len() { + return Err(tl!("Select an existing action step to play").into()); + } + Ok(Event::Preview { draft: self.draft, from }) + } + + fn step(&mut self, app: &mut PhotocraftApp, params: Value) -> Result<(), String> { + let op = params.get("op").and_then(Value::as_str).unwrap_or(""); + let at = params.get("step").and_then(Value::as_u64).and_then(|index| usize::try_from(index).ok()); + let to = params.get("to").and_then(Value::as_u64).and_then(|index| usize::try_from(index).ok()); + let selected = self.selected; + let expanded = self.expanded.clone(); + self.call(app, "step", params.clone())?; + let remap = |index: usize| -> Option { + let Some(at) = at else { return Some(index) }; + match op { + "insert" => Some(if index >= at { index.saturating_add(1) } else { index }), + "duplicate" => Some(if index > at { index.saturating_add(1) } else { index }), + "delete" => { + if index == at { + None + } else { + Some(if index > at { index - 1 } else { index }) + } + } + "move" => { + let Some(to) = to else { return Some(index) }; + Some(if index == at { + to + } else if at < to && index > at && index <= to { + index - 1 + } else if at > to && index >= to && index < at { + index.saturating_add(1) + } else { + index + }) + } + _ => Some(index), + } + }; + self.selected = selected.and_then(remap); + self.expanded = expanded.into_iter().filter_map(remap).collect(); + if op == "insert" { + self.selected = at; + } else if op == "duplicate" { + self.selected = at.map(|index| index.saturating_add(1)); + } + Ok(()) + } + + fn insert_stop(&mut self, view: &Value) { + let list = steps(view); + let at = self.selected.filter(|index| *index < list.len()).map_or(list.len(), |index| index.saturating_add(1)); + let previous = at.checked_sub(1).and_then(|index| list.get(index)).cloned(); + self.editing = Some(StepEdit { + draft: self.draft, + target: Target::Insert { at, len: list.len(), previous }, + command: photocraft_engine::atn_cmds::STOP_MESSAGE.into(), + params: json!({"message": "", "allowContinue": false}).as_object().cloned().unwrap_or_default(), + }); + } + + fn edit_settings(&mut self, view: &Value, at: usize) { + let Some(original) = steps(view).get(at) else { return }; + let Some((command, params)) = step_parts(original) else { return }; + if editable(command) + && let Some(params) = params.as_object() + { + self.editing = Some(StepEdit { + draft: self.draft, + target: Target::Replace { at, original: original.clone() }, + command: command.into(), + params: params.clone(), + }); + } + } + + fn accept_step(&mut self, app: &mut PhotocraftApp) -> Result<(), String> { + let edit = self.editing.as_ref().ok_or("There is no step being edited")?.clone(); + if edit.draft != self.draft { + return Err(tl!("This step dialog belongs to another editor").into()); + } + let view = self.get(app)?; + let list = steps(&view); + let (op, at, valid) = match edit.target { + Target::Replace { at, original } => ("replace", at, list.get(at) == Some(&original)), + Target::Insert { at, len, previous } => { + let current = at.checked_sub(1).and_then(|index| list.get(index)).cloned(); + ("insert", at, list.len() == len && at <= len && current == previous) + } + }; + if !valid { + let error = tl!("The draft steps changed. Cancel this dialog and reopen the step.").to_string(); + self.error = Some(error.clone()); + return Err(error); + } + self.step(app, json!({"op": op, "step": at, "id": edit.command, "params": edit.params}))?; + self.editing = None; + Ok(()) + } + + fn metadata(&mut self, app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &Value, swatches: &[Color32; 12]) -> Option { + let mut latest = None; + let picker_id = ui.scope_id().with(("thumbnail", self.draft)); + let choice = editor_picker::show(ui, picker_id, action_text(view, "icon"), action_text(view, "color"), swatches); + if let Some(choice) = choice { + let params = match choice { + Choice::Icon(icon) => json!({"icon": icon}), + Choice::Color(color) => json!({"color": color}), + }; + latest = self.call(app, "update", params).ok(); + } + let name = ui.label(tl!("Name")); + let name_edit = ui.add(egui::TextEdit::singleline(&mut self.name.text).desired_width(f32::INFINITY)).labelled_by(name.id); + let description = ui.label(tl!("Description")); + let description_edit = ui + .add( + egui::TextEdit::multiline(&mut self.description.text) + .desired_rows(3) + .desired_width(f32::INFINITY) + .hint_text(tl!("Add a brief description for this action…")), + ) + .labelled_by(description.id); + ui.label(tl!("Keyboard shortcut")); + let (key, mut shift, mut control) = photocraft_engine::atn_cmds::parse_function_key(&self.shortcut.text).unwrap_or((0, false, false)); + let mut chosen = key; + let label = if key == 0 && !self.shortcut.text.is_empty() { + self.shortcut.text.clone() + } else if key == 0 { + tl!("None").into() + } else { + format!("F{key}") + }; + let mut shortcut_changed = false; + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("action-editor-fn").selected_text(label).show_ui(ui, |ui| { + for number in 0..=15 { + let label = if number == 0 { tl!("None").to_string() } else { format!("F{number}") }; + shortcut_changed |= ui.selectable_value(&mut chosen, number, label).changed(); + } + }); + ui.add_enabled_ui(chosen > 0, |ui| { + shortcut_changed |= ui.checkbox(&mut shift, tl!("Shift")).changed(); + shortcut_changed |= ui.checkbox(&mut control, if cfg!(target_os = "macos") { tl!("Command") } else { tl!("Control") }).changed(); + }); + }); + if shortcut_changed { + self.shortcut.text = if chosen == 0 { String::new() } else { photocraft_engine::atn_cmds::function_key_name(chosen, shift, control) }; + } + if (name_edit.lost_focus() || description_edit.lost_focus() || shortcut_changed) + && (self.name.dirty() || self.description.dirty() || self.shortcut.dirty()) + { + if let Ok(view) = self.stage(app) { + latest = Some(view); + } + } + latest + } + + fn insert_menu(&mut self, ui: &mut egui::Ui, view: &Value) { + for label in ["Insert Action", "Insert Menu Item"] { + ui.add_enabled(false, egui::Button::new(tl!(label))); + } + if ui.button(tl!("Insert Stop")).clicked() { + self.insert_stop(view); + ui.close(); + } + for label in ["Insert Conditional", "Insert Path"] { + ui.add_enabled(false, egui::Button::new(tl!(label))); + } + } + + fn toolbar(&mut self, app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &Value) -> Option { + let recording = view.get("recording").and_then(Value::as_bool) == Some(true); + let mut event = None; + ui.horizontal(|ui| { + ui.heading(if recording { tl!("Recording") } else { tl!("Steps") }); + if recording { + let (dot, _) = ui.allocate_exact_size(egui::Vec2::splat(8.0), egui::Sense::hover()); + ui.painter().circle_filled(dot.center(), 3.5, Tokens::get(ui.ctx()).danger); + } + if ui.button(if recording { tl!("Stop") } else { tl!("Record") }).clicked() { + let _ = self.call(app, if recording { "stop" } else { "record" }, json!({})); + } + ui.menu_button("+", |ui| self.insert_menu(ui, view)).response.on_hover_text(tl!("Insert…")); + ui.add_enabled_ui(!steps(view).is_empty(), |ui| { + ui.menu_button(tl!("Play…"), |ui| { + if ui.button(tl!("Play action")).clicked() { + event = self.preview(app, 0).ok(); + ui.close(); + } + if ui.add_enabled(self.selected.is_some(), egui::Button::new(tl!("Play from selected step"))).clicked() { + if let Some(from) = self.selected { + event = self.preview(app, from).ok(); + } + ui.close(); + } + }); + let expanded = self.expanded.len() == steps(view).len(); + if ui.button(if expanded { tl!("Collapse all") } else { tl!("Expand all") }).clicked() { + self.expanded = if expanded { BTreeSet::new() } else { (0..steps(view).len()).collect() }; + } + }); + }); + event + } + + fn rows(&mut self, app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &Value) { + if steps(view).is_empty() { + ui.label(tl!("No steps yet")); + ui.label(tl!("Start recording to build your action")); + return; + } + for (at, value) in steps(view).iter().enumerate() { + let Some((command, params)) = step_parts(value) else { + ui.label(tl!("Stored action step")); + continue; + }; + ui.push_id(at, |ui| { + let mut enabled = !flagged(view, "disabled", at); + let mut dialog = flagged(view, "dialogs", at); + let row_rect = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); + let controls = ui.input(|input| input.pointer.hover_pos()).is_some_and(|point| row_rect.contains(point) && ui.clip_rect().contains(point)) + || self.selected == Some(at); + ui.horizontal(|ui| { + let expanded = self.expanded.contains(&at); + if icons::button(ui, if expanded { "chevron-down" } else { "chevron-right" }, 20.0, false, tl!("Show step settings")).clicked() { + if expanded { + self.expanded.remove(&at); + } else { + self.expanded.insert(at); + } + } + let label = super::step_label(command, params); + let text = egui::RichText::new(label).color(if enabled { Tokens::get(ui.ctx()).text } else { Tokens::get(ui.ctx()).text_faint }); + let row = ui.selectable_label(self.selected == Some(at), text); + if row.clicked() { + self.selected = Some(at); + } + if controls || !enabled { + if ui.checkbox(&mut enabled, "").on_hover_text(tl!("Enable step")).changed() { + let _ = self.step(app, json!({"op": "enabled", "step": at, "enabled": enabled})); + } + } else { + ui.allocate_exact_size(egui::Vec2::splat(18.0), egui::Sense::hover()); + } + if controls { + if ui.checkbox(&mut dialog, "").on_hover_text(tl!("Show dialog during playback")).changed() { + let _ = self.step(app, json!({"op": "dialog", "step": at, "enabled": dialog})); + } + } else { + ui.allocate_exact_size(egui::Vec2::splat(18.0), egui::Sense::hover()); + } + if editable(command) { + if icons::button(ui, "sliders-horizontal", 24.0, false, tl!("Edit step settings")).clicked() { + self.edit_settings(view, at); + } + } else { + ui.allocate_exact_size(egui::Vec2::splat(24.0), egui::Sense::hover()); + } + if controls { + let menu = icons::button(ui, "ellipsis", 24.0, false, tl!("Steps")); + egui::Popup::menu(&menu).show(|ui| { + self.selected = Some(at); + self.insert_menu(ui, view); + ui.separator(); + for (label, op) in [(tl!("Duplicate step"), "duplicate"), (tl!("Delete step"), "delete")] { + if ui.button(label).clicked() { + let _ = self.step(app, json!({"op": op, "step": at})); + ui.close(); + } + } + if ui.add_enabled(at > 0, egui::Button::new(tl!("Move step up"))).clicked() { + let _ = self.step(app, json!({"op": "move", "step": at, "to": at.saturating_sub(1)})); + ui.close(); + } + if ui.add_enabled(at.saturating_add(1) < steps(view).len(), egui::Button::new(tl!("Move step down"))).clicked() { + let _ = self.step(app, json!({"op": "move", "step": at, "to": at.saturating_add(1)})); + ui.close(); + } + }); + } else { + ui.allocate_exact_size(egui::Vec2::splat(24.0), egui::Sense::hover()); + } + }); + if self.expanded.contains(&at) + && let Some(params) = params.as_object() + { + ui.indent("settings", |ui| { + for (key, value) in params.iter().filter(|(key, _)| !key.starts_with("__")) { + ui.label(format!("{}: {}", filter_dialog::label(key), summary(value))); + } + }); + } + }); + } + } + + fn confirmation(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context, activity: Activity) -> Option { + self.prompt?; + let mut answer = None; + let modal = egui::Modal::new(Id::unique("action-editor-save").with(self.draft)).show(ctx, |ui| { + ui.heading(tl!("Save Action")); + ui.label(tl!("There are unsaved changes. Do you want to save or discard them?")); + ui.checkbox(&mut self.dont_show_again, tl!("Don't show again")); + if let Some(error) = &self.error { + ui.colored_label(Tokens::get(ui.ctx()).danger, error); + } + ui.horizontal(|ui| { + let enabled = !activity.playback_busy && !activity.finishing_step; + let save = ui.add_enabled(enabled, egui::Button::new(tl!("Save"))); + if !self.prompt_focused && enabled { + save.request_focus(); + self.prompt_focused = true; + } + if save.clicked() { + answer = Some(Answer::Save); + } + if ui.add_enabled(!activity.playback_busy, egui::Button::new(tl!("Discard"))).clicked() { + answer = Some(Answer::Discard); + } + if ui.button(tl!("Cancel and continue editing")).clicked() { + answer = Some(Answer::Cancel); + } + }); + }); + if modal.should_close() && answer.is_none() { + answer = Some(Answer::Cancel); + } + answer.and_then(|answer| self.answer(app, answer, activity).ok()).flatten() + } + + fn step_dialog(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(edit) = self.editing.as_mut() else { return }; + let mut accept = false; + let mut cancel = false; + let modal = egui::Modal::new(Id::unique("action-editor-step").with(self.draft)).show(ctx, |ui| { + ui.heading(if matches!(&edit.target, Target::Insert { .. }) { tl!("Insert Stop") } else { tl!("Edit step settings") }); + if edit.command == photocraft_engine::atn_cmds::STOP_MESSAGE { + stop_fields(ui, &mut edit.params); + } else if let Some(spec) = photocraft_engine::commands::find(&edit.command) { + for param in filter_dialog::parse_spec(spec.params).into_iter().filter(|param| !param.key.starts_with("__")) { + field(ui, ¶m, &mut edit.params); + } + } + if let Some(error) = &self.error { + ui.colored_label(Tokens::get(ui.ctx()).danger, error); + } + ui.horizontal(|ui| { + accept = ui.button(tl!("OK")).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + }); + if cancel || modal.should_close() { + self.editing = None; + } else if accept { + let _ = self.accept_step(app); + } + } + + /// Render only after parent extraction from its runtime state, passing the shared adapter's activity. + pub fn show(&mut self, app: &mut PhotocraftApp, ui: &mut egui::Ui, swatches: &[Color32; 12], activity: Activity) -> Option { + if self.closed { + return None; + } + let mut view = match self.get(app) { + Ok(view) => view, + Err(_) => { + ui.label(&self.name.text); + if let Some(error) = &self.error { + ui.colored_label(Tokens::get(ui.ctx()).danger, error); + } + let event = self.confirmation(app, ui.ctx(), activity); + self.step_dialog(app, ui.ctx()); + return event; // Never rebind a stale page to whichever draft is now open. + } + }; + let mut event = None; + ui.push_id(self.draft, |ui| { + ui.add_enabled_ui(!activity.playback_busy && !self.has_modal(), |ui| { + if icons::button(ui, "chevron-left", 28.0, false, tl!("Back")).clicked() { + event = self.request_leave(app, activity).ok().flatten(); + } + if self.closed || self.has_modal() { + return; + } + if let Some(updated) = self.metadata(app, ui, &view, swatches) { + view = updated; + } + event = self.toolbar(app, ui, &view).or(event.take()); + egui::ScrollArea::vertical() + .id_salt("draft-steps") + .max_height((ui.available_height() - 48.0).max(100.0)) + .show(ui, |ui| self.rows(app, ui, &view)); + if activity.finishing_step { + ui.label(tl!("Finishing step…")); + } + if ui.add_enabled(self.dirty(&view) && !activity.finishing_step, egui::Button::new(tl!("Save"))).clicked() { + event = self.ask_save(app, activity).ok().flatten().or(event.take()); + } + }); + if let Some(error) = &self.error { + ui.colored_label(Tokens::get(ui.ctx()).danger, error); + } + }); + if self.prompt.is_some() { + event = self.confirmation(app, ui.ctx(), activity).or(event); + } + if self.editing.is_some() { + self.step_dialog(app, ui.ctx()); + } + event + } + + /// A pending leave question must remain visible when another dock tab is active. + pub(super) fn show_modals(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context, activity: Activity) -> Option { + let event = if self.prompt.is_some() { self.confirmation(app, ctx, activity) } else { None }; + if self.editing.is_some() { + self.step_dialog(app, ctx); + } + event + } +} + +fn stop_fields(ui: &mut egui::Ui, values: &mut Map) { + let label = ui.label(tl!("Message:")); + let mut message = values.get("message").and_then(Value::as_str).unwrap_or("").to_string(); + if ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0)).labelled_by(label.id).changed() { + values.insert("message".into(), json!(message)); + } + let mut allow = values.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut allow, tl!("Allow Continue")).changed() { + values.insert("allowContinue".into(), json!(allow)); + } +} + +/// Pure definition widgets: only explicitly changed fields are staged, retaining unknown params. +fn field(ui: &mut egui::Ui, param: &filter_dialog::Param, values: &mut Map) { + use filter_dialog::Kind; + let label = filter_dialog::label(¶m.key); + let mut update = None; + ui.horizontal(|ui| { + let named = ui.label(&label); + match ¶m.kind { + Kind::Bool(default) => { + let mut value = values.get(¶m.key).and_then(Value::as_bool).unwrap_or(*default); + if ui.checkbox(&mut value, "").labelled_by(named.id).changed() { + update = Some(json!(value)); + } + } + Kind::Text => { + let mut value = values.get(¶m.key).and_then(Value::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut value).labelled_by(named.id).changed() { + update = Some(json!(value)); + } + } + Kind::Int { default } => { + let mut value = values.get(¶m.key).and_then(Value::as_i64).unwrap_or(*default); + if ui.add(egui::DragValue::new(&mut value)).labelled_by(named.id).changed() { + update = Some(json!(value)); + } + } + Kind::Range { min, max, default } if min.is_finite() && max.is_finite() && min <= max => { + let stored = values.get(¶m.key).and_then(Value::as_f64).filter(|value| value.is_finite()).unwrap_or(f64::from(*default)); + let mut value = stored.clamp(f64::from(*min), f64::from(*max)); + if ui.add(egui::DragValue::new(&mut value).range(f64::from(*min)..=f64::from(*max))).labelled_by(named.id).changed() { + update = Some(json!(value)); + } + } + Kind::Choice { options, default } => { + let mut value = values + .get(¶m.key) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| default.clone()) + .or_else(|| options.first().cloned()) + .unwrap_or_default(); + egui::ComboBox::from_id_salt(¶m.key).selected_text(&value).show_ui(ui, |ui| { + for option in options { + if ui.selectable_value(&mut value, option.clone(), tl!(option)).changed() { + update = Some(json!(value)); + } + } + }); + } + _ => { + ui.label(values.get(¶m.key).map(summary).unwrap_or_else(|| tl!("None").to_string())); + } + } + }); + if let Some(value) = update { + values.insert(param.key.clone(), value); + } +} + +#[cfg(test)] +#[path = "editor_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs b/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs new file mode 100644 index 00000000000..953819cf90c --- /dev/null +++ b/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs @@ -0,0 +1,132 @@ +//! Source contract tests; no rasterization or app launch. + +#[path = "editor_glyphs.rs"] +mod glyphs; + +use craft_ui::icons::{SvgIconSet, SvgStyle}; + +use glyphs::{GLYPH_COLUMNS, GLYPHS, display_glyph}; + +// Test-only embed table. Production registration belongs with the real editor consumer. +static ASSETS: &[(&str, &[u8])] = &[ + ("action-editor-click", include_bytes!("../../../../../assets/icons/action-editor/click.svg")), + ("action-editor-crop", include_bytes!("../../../../../assets/icons/action-editor/crop.svg")), + ("action-editor-merge", include_bytes!("../../../../../assets/icons/action-editor/merge.svg")), + ("action-editor-adjust", include_bytes!("../../../../../assets/icons/action-editor/adjust.svg")), + ("action-editor-hue", include_bytes!("../../../../../assets/icons/action-editor/hue.svg")), + ("action-editor-exposure", include_bytes!("../../../../../assets/icons/action-editor/exposure.svg")), + ("action-editor-lut", include_bytes!("../../../../../assets/icons/action-editor/lut.svg")), + ("action-editor-black-white", include_bytes!("../../../../../assets/icons/action-editor/black-white.svg")), + ("action-editor-smart-remove", include_bytes!("../../../../../assets/icons/action-editor/smart-remove.svg")), + ("action-editor-dodge", include_bytes!("../../../../../assets/icons/action-editor/dodge.svg")), + ("action-editor-burn", include_bytes!("../../../../../assets/icons/action-editor/burn.svg")), + ("action-editor-erase", include_bytes!("../../../../../assets/icons/action-editor/erase.svg")), + ("action-editor-layer-effects", include_bytes!("../../../../../assets/icons/action-editor/layer-effects.svg")), + ("action-editor-generative-effects", include_bytes!("../../../../../assets/icons/action-editor/generative-effects.svg")), + ("action-editor-sharpen", include_bytes!("../../../../../assets/icons/action-editor/sharpen.svg")), + ("action-editor-object-select", include_bytes!("../../../../../assets/icons/action-editor/object-select.svg")), + ("action-editor-wand", include_bytes!("../../../../../assets/icons/action-editor/wand.svg")), + ("action-editor-generate", include_bytes!("../../../../../assets/icons/action-editor/generate.svg")), + ("action-editor-text-color", include_bytes!("../../../../../assets/icons/action-editor/text-color.svg")), + ("action-editor-text", include_bytes!("../../../../../assets/icons/action-editor/text.svg")), + ("action-editor-download", include_bytes!("../../../../../assets/icons/action-editor/download.svg")), + ("action-editor-layers", include_bytes!("../../../../../assets/icons/action-editor/layers.svg")), + ("action-editor-brush", include_bytes!("../../../../../assets/icons/action-editor/brush.svg")), + ("action-editor-subject", include_bytes!("../../../../../assets/icons/action-editor/subject.svg")), + ("action-editor-remove-background", include_bytes!("../../../../../assets/icons/action-editor/remove-background.svg")), + ("action-editor-blur", include_bytes!("../../../../../assets/icons/action-editor/blur.svg")), + ("action-editor-harmonize", include_bytes!("../../../../../assets/icons/action-editor/harmonize.svg")), + ("action-editor-clarity", include_bytes!("../../../../../assets/icons/action-editor/clarity.svg")), + ("action-editor-styles", include_bytes!("../../../../../assets/icons/action-editor/styles.svg")), + ("action-editor-grid", include_bytes!("../../../../../assets/icons/action-editor/grid.svg")), + ("square", include_bytes!("../../../../../assets/icons/square.svg")), +]; + +#[test] +fn measured_picker_order_is_complete_and_unique() { + // Transcribed from the issue's five measured rows, independently of the asset catalog. + let expected = [ + "ClickAction", + "ActionsCrop", + "LayerMerge", + "AdjustmentGeneral", + "HueAndSat", + "ActionsExposure", + "ColorLookUpTables", + "BlackAndWhite", + "SmartRemove", + "ActionsDodge", + "ActionsBurn", + "ActionsErase", + "LayerEffects", + "AIGenEffects", + "Sharpen", + "ObjectSelectionLasso", + "MagicWand", + "AIGenerate", + "TextColor", + "ActionsText", + "ActionsDownload", + "ActionsLayers", + "Brush", + "SelectSubject", + "RemoveBackground", + "GaussianBlur", + "LayerHarmonize", + "P_AdjustClarity_32x32", + "AIStyles", + "ShowGrid", + ]; + assert_eq!(GLYPH_COLUMNS, 6); + assert_eq!(GLYPHS.map(|(identifier, _)| identifier), expected); + assert_eq!(GLYPHS.len() / GLYPH_COLUMNS, 5); + assert_eq!(ASSETS.len(), GLYPHS.len() + 1); + let mut identifiers = std::collections::HashSet::new(); + let mut names = std::collections::HashSet::new(); + for ((identifier, name), (embedded_name, bytes)) in GLYPHS.iter().zip(ASSETS) { + assert!(identifiers.insert(*identifier)); + assert!(names.insert(*name)); + assert_eq!(name, embedded_name); + assert_eq!(display_glyph(identifier), *name); + assert!(!bytes.is_empty()); + } +} + +#[test] +fn unknown_identifiers_use_the_neutral_display_fallback() { + for identifier in ["", "clickaction", "ClickAction ", "future-action-icon", "图标"] { + assert_eq!(display_glyph(identifier), "square"); + } + // This API only borrows metadata; the caller keeps the original value for persistence. + let stored = String::from("future-action-icon"); + assert_eq!(display_glyph(&stored), "square"); + assert_eq!(stored, "future-action-icon"); +} + +#[test] +fn original_assets_follow_the_existing_svg_source_pipeline() { + let icons = SvgIconSet::new(ASSETS, SvgStyle { current_color: "#ffffff", stroke_width: Some("1.75") }, "bytes://icons/", "square"); + for (_, name) in GLYPHS { + assert!(icons.contains(name)); + let original = ASSETS.iter().find(|(key, _)| *key == name).unwrap().1; + let source = std::str::from_utf8(original).unwrap(); + assert!(source.contains("viewBox=\"0 0 24 24\"")); + assert!(source.contains("stroke=\"currentColor\"")); + assert!(source.contains("stroke-width=\"2\"")); + for forbidden in ["href=", " &'static str { + GLYPHS.iter().find_map(|&(key, asset)| (key == identifier).then_some(asset)).unwrap_or("square") +} diff --git a/crates/ui-egui/src/panels/actions/editor_picker.rs b/crates/ui-egui/src/panels/actions/editor_picker.rs new file mode 100644 index 00000000000..857d1a0087f --- /dev/null +++ b/crates/ui-egui/src/panels/actions/editor_picker.rs @@ -0,0 +1,158 @@ +//! Stateless Actions editor thumbnail picker. The caller owns metadata and stages explicit choices. +use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; +use egui::{Color32, Id, Stroke, Vec2}; +use photocraft_engine::actions_cmds::BUTTON_COLORS; + +use super::editor_glyphs::{GLYPH_COLUMNS, GLYPHS, display_glyph}; +use crate::{icons, theme::Tokens}; + +/// A deliberate click, not a normalized copy of the current metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + /// Empty selects the default glyph; this is distinct from an explicit ClickAction choice. + Icon(&'static str), + /// A measured colour name (empty for none), independent of the current icon. + Color(&'static str), +} + +const CELL: f32 = 32.0; +const GLYPH_LABELS: [&str; 30] = [ + "Click action", + "Crop", + "Merge layers", + "Adjustments", + "Hue and saturation", + "Exposure", + "Colour lookup tables", + "Black and white", + "Smart removal", + "Dodge", + "Burn", + "Erase", + "Layer effects", + "Generated layer effects", + "Sharpen", + "Object selection", + "Magic wand", + "Generate", + "Text colour", + "Text", + "Download", + "Layers", + "Brush", + "Select subject", + "Remove background", + "Blur", + "Harmonize layers", + "Clarity", + "Generated styles", + "Show grid", +]; + +// Picker order differs from ATN index order, especially the two blues and the new teal. +const COLOR_GRID: [(&str, &str); 12] = [ + ("", "None"), + ("red", "Red"), + ("orange", "Orange"), + ("yellow", "Yellow"), + ("green", "Green"), + ("teal", "Teal"), + ("blue", "Light blue"), + ("blue2", "Blue"), + ("magenta", "Magenta"), + ("purple", "Purple"), + ("violet", "Lavender"), + ("gray", "Grey"), +]; + +fn thumbnail_style(color: &str, swatches: &[Color32; 12], tokens: &Tokens) -> (Color32, Color32) { + let index = BUTTON_COLORS.iter().position(|name| *name == color).filter(|index| *index > 0); + match index.and_then(|index| swatches.get(index)).copied() { + Some(fill) => (fill, Color32::WHITE), + None => (tokens.card, tokens.text), + } +} + +fn color_button(ui: &mut egui::Ui, color: &str, label: &str, selected: bool, swatches: &[Color32; 12], tokens: &Tokens) -> egui::Response { + let (fill, ink) = thumbnail_style(color, swatches, tokens); + let style = IconButtonStyle { + normal: ButtonVisuals { fill, stroke: Stroke::new(1.0, tokens.field_border), icon: ink }, + hovered: ButtonVisuals { fill, stroke: Stroke::new(1.0, tokens.text), icon: ink }, + pressed: None, + selected: ButtonVisuals { fill, stroke: Stroke::new(2.0, tokens.accent_border), icon: ink }, + corner_radius: tokens.radius_sm.into(), + focus_stroke: Stroke::new(1.0, tokens.accent), + }; + IconButton::new(tl!(label), Vec2::splat(CELL), &style) + .selected(selected) + .show(ui, |ui, rect, tint| { + if color.is_empty() { + let slash = [rect.left_bottom() + egui::vec2(5.0, -5.0), rect.right_top() + egui::vec2(-5.0, 5.0)]; + ui.painter().line_segment(slash, Stroke::new(1.5, tint)); + } else if selected { + icons::paint(ui, rect, "check", 16.0, tint); + } + }) + .on_hover_text(tl!(label)) +} + +fn contents(ui: &mut egui::Ui, icon: &str, color: &str, swatches: &[Color32; 12], tokens: &Tokens) -> Option { + let mut choice = None; + ui.label(tl!("Choose icon")); + if icons::button(ui, "square", CELL, icon.is_empty(), tl!("Default icon")).clicked() { + choice = Some(Choice::Icon("")); + } + egui::Grid::new("action-editor-glyph-grid").num_columns(GLYPH_COLUMNS).spacing(egui::vec2(4.0, 4.0)).show(ui, |ui| { + for (index, ((identifier, asset), label)) in GLYPHS.into_iter().zip(GLYPH_LABELS).enumerate() { + if icons::button(ui, asset, CELL, icon == identifier, label).clicked() { + choice = Some(Choice::Icon(identifier)); + } + if index % GLYPH_COLUMNS == GLYPH_COLUMNS - 1 { + ui.end_row(); + } + } + }); + ui.separator(); + ui.label(tl!("Choose color")); + egui::Grid::new("action-editor-color-grid").num_columns(6).spacing(egui::vec2(4.0, 4.0)).show(ui, |ui| { + for (index, (name, label)) in COLOR_GRID.into_iter().enumerate() { + if color_button(ui, name, label, color == name, swatches, tokens).clicked() { + choice = Some(Choice::Color(name)); + } + if index % 6 == 5 { + ui.end_row(); + } + } + }); + if choice.is_some() { + ui.close(); + } + choice +} + +/// Draw the thumbnail and its picker, returning only an explicit selection. +/// +/// `id` scopes popup/widget state for this editor. `icon`/`color` are authoritative raw values; +/// unknowns display neutrally and are never returned as replacements without a click. `swatches` +/// are caller-selected PhotoCraft colours in ATN index order, not measured reference RGB samples. +/// Slot zero is ignored in favour of the neutral theme surface. Register the thirty glyph assets +/// through the existing icon table when connecting this leaf to the actual editor consumer. +pub fn show(ui: &mut egui::Ui, id: Id, icon: &str, color: &str, swatches: &[Color32; 12]) -> Option { + let tokens = Tokens::get(ui.ctx()); + ui.push_id(id, |ui| { + let mut choice = None; + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(48.0), egui::Sense::hover()); + let (fill, ink) = thumbnail_style(color, swatches, &tokens); + ui.painter().rect_filled(rect, tokens.radius_sm, fill); + icons::paint(ui, rect, display_glyph(icon), 28.0, ink); + ui.menu_button(tl!("Choose icon"), |ui| choice = contents(ui, icon, color, swatches, &tokens)); + }); + choice + }) + .inner +} + +#[cfg(test)] +#[path = "editor_picker_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs new file mode 100644 index 00000000000..9a5b4c68e42 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs @@ -0,0 +1,192 @@ +//! CPU egui interaction/layout checks; no GPU or image-golden qualification. +use egui::{Color32, Id, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable}; + +use super::{Choice, show, thumbnail_style}; +use crate::{ + PhotocraftApp, + theme::{ThemeKind, Tokens}, +}; + +const UNKNOWN_ICON: &str = " future-icon-图标 "; +const UNKNOWN_COLOR: &str = "@atn-color:65535"; + +struct State { + icon: String, + color: String, + choices: Vec, +} + +// Synthetic indexed sentinels, not a proposed application/reference palette. +fn swatches() -> [Color32; 12] { + std::array::from_fn(|index| Color32::from_rgb(index as u8, 31, 47)) +} + +fn harness(icon: &str, color: &str) -> Harness<'static, State> { + let state = State { icon: icon.into(), color: color.into(), choices: Vec::new() }; + let mut h = Harness::builder().with_size(egui::vec2(600.0, 700.0)).build_ui_state( + |ui, state: &mut State| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + if let Some(choice) = show(ui, Id::unique("action-editor-picker-test"), &state.icon, &state.color, &swatches()) { + state.choices.push(choice); + } + }, + state, + ); + PhotocraftApp::setup_context(&h.ctx, ThemeKind::Studio); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, State>) { + assert!(!egui::Popup::is_any_open(&h.ctx)); + h.get_by_role_and_label(Role::Button, "Choose icon").click(); + h.run_steps(3); +} + +fn choose(h: &mut Harness<'_, State>, label: &str, expected: Choice) { + let before = h.state().choices.len(); + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(3); + assert_eq!(h.state().choices.len(), before + 1, "one deliberate choice for {label}"); + assert_eq!(h.state().choices.last(), Some(&expected)); + assert!(!egui::Popup::is_any_open(&h.ctx)); +} + +fn grid(h: &Harness<'_, State>, labels: &[&str], columns: usize) { + let positions: Vec<_> = labels.iter().map(|label| h.get_by_role_and_label(Role::Button, label).rect().center()).collect(); + for row in positions.chunks(columns) { + assert!(row.iter().all(|point| (point.y - row[0].y).abs() < 0.5)); + assert!(row.windows(2).all(|pair| pair[0].x < pair[1].x)); + } + for rows in positions.chunks(columns).collect::>().windows(2) { + assert!(rows[0][0].y < rows[1][0].y); + assert!((rows[0][0].x - rows[1][0].x).abs() < 0.5); + } +} + +#[test] +fn idle_and_dismissed_picker_preserve_unknown_metadata_without_a_choice() { + let mut h = harness(UNKNOWN_ICON, UNKNOWN_COLOR); + h.run_steps(3); + assert!(h.state().choices.is_empty()); + open(&mut h); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(h.state().choices.is_empty()); + assert_eq!(h.state().icon, UNKNOWN_ICON); + assert_eq!(h.state().color, UNKNOWN_COLOR); +} + +#[test] +fn default_icon_is_an_explicit_choice_distinct_from_click_action() { + let mut h = harness(UNKNOWN_ICON, "blue2"); + open(&mut h); + choose(&mut h, "Default icon", Choice::Icon("")); + assert_eq!(h.state().icon, UNKNOWN_ICON, "the caller still owns the raw value"); + h.state_mut().icon.clear(); // The caller acknowledges the deliberate default selection. + open(&mut h); + choose(&mut h, "Click action", Choice::Icon("ClickAction")); + assert!(h.state().icon.is_empty()); + assert_eq!(h.state().color, "blue2"); +} + +#[test] +fn six_by_five_icon_grid_returns_the_measured_identifier_for_each_button() { + for (_, asset) in super::GLYPHS { + assert!(crate::icons::exists(asset), "the real consumer must embed {asset}; fallback squares are not glyph registration"); + } + // The issue's measured row order paired with human-readable button labels. + let expected = [ + ("ClickAction", "Click action"), + ("ActionsCrop", "Crop"), + ("LayerMerge", "Merge layers"), + ("AdjustmentGeneral", "Adjustments"), + ("HueAndSat", "Hue and saturation"), + ("ActionsExposure", "Exposure"), + ("ColorLookUpTables", "Colour lookup tables"), + ("BlackAndWhite", "Black and white"), + ("SmartRemove", "Smart removal"), + ("ActionsDodge", "Dodge"), + ("ActionsBurn", "Burn"), + ("ActionsErase", "Erase"), + ("LayerEffects", "Layer effects"), + ("AIGenEffects", "Generated layer effects"), + ("Sharpen", "Sharpen"), + ("ObjectSelectionLasso", "Object selection"), + ("MagicWand", "Magic wand"), + ("AIGenerate", "Generate"), + ("TextColor", "Text colour"), + ("ActionsText", "Text"), + ("ActionsDownload", "Download"), + ("ActionsLayers", "Layers"), + ("Brush", "Brush"), + ("SelectSubject", "Select subject"), + ("RemoveBackground", "Remove background"), + ("GaussianBlur", "Blur"), + ("LayerHarmonize", "Harmonize layers"), + ("P_AdjustClarity_32x32", "Clarity"), + ("AIStyles", "Generated styles"), + ("ShowGrid", "Show grid"), + ]; + let mut h = harness(UNKNOWN_ICON, UNKNOWN_COLOR); + open(&mut h); + grid(&h, &expected.map(|(_, label)| label), 6); + for (index, (identifier, label)) in expected.into_iter().enumerate() { + if index > 0 { + open(&mut h); + } + choose(&mut h, label, Choice::Icon(identifier)); + } + assert_eq!(h.state().icon, UNKNOWN_ICON); + assert_eq!(h.state().color, UNKNOWN_COLOR); +} + +#[test] +fn two_by_six_colour_grid_keeps_classic_blue_and_new_blue_distinct() { + let expected = [ + ("", "None"), + ("red", "Red"), + ("orange", "Orange"), + ("yellow", "Yellow"), + ("green", "Green"), + ("teal", "Teal"), + ("blue", "Light blue"), + ("blue2", "Blue"), + ("magenta", "Magenta"), + ("purple", "Purple"), + ("violet", "Lavender"), + ("gray", "Grey"), + ]; + let mut h = harness(UNKNOWN_ICON, UNKNOWN_COLOR); + open(&mut h); + grid(&h, &expected.map(|(_, label)| label), 6); + for (index, (name, label)) in expected.into_iter().enumerate() { + if index > 0 { + open(&mut h); + } + choose(&mut h, label, Choice::Color(name)); + } + assert_eq!(h.state().icon, UNKNOWN_ICON); + assert_eq!(h.state().color, UNKNOWN_COLOR); +} + +#[test] +fn thumbnail_uses_the_callers_indexed_palette_and_theme_neutral_fallback() { + let palette = swatches(); + for kind in [ThemeKind::Pro, ThemeKind::ProLight] { + let tokens = Tokens::for_kind(kind); + for unknown_or_none in ["", UNKNOWN_COLOR, "future-color", "Blue", "blue2 "] { + assert_eq!(thumbnail_style(unknown_or_none, &palette, &tokens), (tokens.card, tokens.text)); + } + assert_eq!(thumbnail_style("blue", &palette, &tokens), (palette[5], Color32::WHITE)); + assert_eq!(thumbnail_style("blue2", &palette, &tokens), (palette[9], Color32::WHITE)); + assert_ne!(palette[5], palette[9]); + } + assert_eq!(super::display_glyph(UNKNOWN_ICON), "square"); + assert_eq!(super::display_glyph(""), "square"); + assert_eq!(super::display_glyph("ClickAction"), "action-editor-click"); +} diff --git a/crates/ui-egui/src/panels/actions/editor_tests.rs b/crates/ui-egui/src/panels/actions/editor_tests.rs new file mode 100644 index 00000000000..05715d1facb --- /dev/null +++ b/crates/ui-egui/src/panels/actions/editor_tests.rs @@ -0,0 +1,333 @@ +//! Owned page/command behavior. These tests do not qualify the final page visually. +use std::{cell::RefCell, rc::Rc}; + +use photocraft_engine::{ + Session, + preset_store::{self, MemBackend}, +}; +use serde_json::json; + +use super::*; + +fn app(services: crate::Services) -> (PhotocraftApp, MemBackend) { + let mut session = Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + session.execute("actions.record", json!({"name": "Original"})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Ink"})).unwrap(); + session.execute("actions.stop", json!({})).unwrap(); + let mem = MemBackend::default(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + (PhotocraftApp::new(session, services), mem) +} + +fn existing(app: &mut PhotocraftApp) -> Page { + Page::open(app, json!({"action": "Original"})).unwrap() +} + +fn store(mem: &MemBackend) -> std::collections::BTreeMap> { + mem.files.lock().unwrap().clone() +} + +#[test] +fn untouched_new_page_keeps_one_default_and_leaves_without_publishing() { + let (mut app, mem) = app(Default::default()); + let before = app.session.actions.list.clone(); + let persisted = store(&mem); + let mut page = Page::open(&mut app, json!({})).unwrap(); + let name = page.name.text.clone(); + assert_eq!(name, "Action 2"); + assert_eq!(action_text(&page.get(&mut app).unwrap(), "name"), name); + let view = page.get(&mut app).unwrap(); + assert!(!page.dirty(&view)); + assert_eq!(page.request_leave(&mut app, Activity::default()).unwrap(), Some(Event::Closed { saved: false, source: None })); + assert_eq!(app.session.actions.list, before); + assert_eq!(store(&mem), persisted); +} + +#[test] +fn cancel_keeps_local_buffers_and_capture_discard_keeps_real_document_edits() { + let (mut app, mem) = app(Default::default()); + let before = app.session.actions.list.clone(); + let persisted = store(&mem); + let mut page = existing(&mut app); + page.name.text = "Buffered name".into(); + page.ask_save(&mut app, Activity::default()).unwrap(); + page.dont_show_again = true; + assert_eq!(page.answer(&mut app, Answer::Cancel, Activity::default()).unwrap(), None); + assert_eq!(page.name.text, "Buffered name"); + assert!(!Page::suppressed(&app)); + page.call(&mut app, "record", json!({})).unwrap(); + app.run("layer.new.layer", json!({"name": "Recorded edit"})).unwrap(); + let document = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + assert_eq!(page.request_leave(&mut app, Activity::default()).unwrap(), None); + assert_eq!(page.answer(&mut app, Answer::Cancel, Activity::default()).unwrap(), Some(Event::CancelledLeave)); + assert_eq!(page.get(&mut app).unwrap()["recording"], true); + page.request_leave(&mut app, Activity::default()).unwrap(); + page.dont_show_again = true; + assert!(matches!(page.answer(&mut app, Answer::Discard, Activity::default()).unwrap(), Some(Event::Closed { saved: false, .. }))); + assert_eq!(app.session.actions.list, before); + assert_eq!(store(&mem), persisted); + assert_eq!(app.session.active().unwrap().doc, document); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(!Page::suppressed(&app)); +} + +#[test] +fn explicit_save_stays_open_and_dirty_navigation_saves_then_closes_owned_handle() { + let (mut app, mem) = app(Default::default()); + let mut page = existing(&mut app); + page.name.text = "Saved once".into(); + page.description.text = " Description\nwith spacing ".into(); + let before = app.session.actions.list.clone(); + page.ask_save(&mut app, Activity::default()).unwrap(); + assert_eq!(app.session.actions.list, before); + assert_eq!(page.answer(&mut app, Answer::Save, Activity::default()).unwrap(), Some(Event::Saved { source: Some(0) })); + assert!(!page.closed); + assert_eq!(app.session.actions.list[0].name, "Saved once"); + assert_eq!(app.session.actions.list[0].description, " Description\nwith spacing "); + page.name.text = "Saved on leaving".into(); + page.request_leave(&mut app, Activity::default()).unwrap(); + assert_eq!(page.answer(&mut app, Answer::Save, Activity::default()).unwrap(), Some(Event::Closed { saved: true, source: Some(0) })); + assert!(app.run("actions.editor.get", json!({"draft": page.draft()})).is_err()); + let mut reopened = Session::new(); + assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem))).is_empty()); + assert_eq!(reopened.actions.list, app.session.actions.list); +} + +#[test] +fn invalid_unsent_name_keeps_prompt_and_buffers_without_persisting_suppression() { + let writes = Rc::new(RefCell::new(0)); + let calls = writes.clone(); + let (mut app, mem) = app(crate::Services { + save_prefs: Some(Box::new(move |_| { + *calls.borrow_mut() += 1; + Ok(()) + })), + ..Default::default() + }); + let mut page = existing(&mut app); + let before = app.session.actions.list.clone(); + let persisted = store(&mem); + page.name.text = " ".into(); + page.description.text = "Retain this buffer".into(); + page.ask_save(&mut app, Activity::default()).unwrap(); + page.dont_show_again = true; + assert!(page.answer(&mut app, Answer::Save, Activity::default()).is_err()); + assert_eq!(page.prompt, Some(Intent::Save)); + assert_eq!(page.name.text, " "); + assert_eq!(page.description.text, "Retain this buffer"); + assert!(page.error.is_some()); + assert_eq!(app.session.actions.list, before); + assert_eq!(store(&mem), persisted); + assert_eq!(*writes.borrow(), 0); + assert!(!Page::suppressed(&app)); + page.answer(&mut app, Answer::Discard, Activity::default()).unwrap(); +} + +#[test] +fn stale_save_keeps_the_draft_and_discard_does_not_restore_the_old_library() { + let (mut app, mem) = app(Default::default()); + let mut page = existing(&mut app); + page.description.text = "Private edit".into(); + page.ask_save(&mut app, Activity::default()).unwrap(); + app.run("actions.rename", json!({"action": "Original", "name": "External rename"})).unwrap(); + let external = app.session.actions.list.clone(); + let persisted = store(&mem); + assert!(page.answer(&mut app, Answer::Save, Activity::default()).is_err()); + assert_eq!(page.prompt, Some(Intent::Save)); + assert_eq!(page.description.text, "Private edit"); + assert_eq!(app.session.actions.list, external); + page.answer(&mut app, Answer::Discard, Activity::default()).unwrap(); + assert_eq!(app.session.actions.list, external); + assert_eq!(store(&mem), persisted); +} + +#[test] +fn definition_edit_cancel_insert_and_step_flags_never_execute_document_commands() { + let (mut app, mem) = app(Default::default()); + let mut page = existing(&mut app); + let library = app.session.actions.list.clone(); + let persisted = store(&mem); + let document = app.session.active().unwrap().doc.clone(); + let history = app.session.active().unwrap().history.past_len(); + let view = page.get(&mut app).unwrap(); + page.edit_settings(&view, 0); + page.editing.as_mut().unwrap().params.insert("name".into(), json!("Definition only")); + page.accept_step(&mut app).unwrap(); + let view = page.get(&mut app).unwrap(); + assert_eq!(steps(&view)[0][1]["name"], "Definition only"); + page.edit_settings(&view, 0); + page.editing.as_mut().unwrap().params.insert("name".into(), json!("Cancelled")); + page.editing = None; + assert_eq!(page.get(&mut app).unwrap(), view); + page.selected = Some(0); + page.insert_stop(&view); + page.editing.as_mut().unwrap().params.insert("message".into(), json!("Read this stop")); + page.accept_step(&mut app).unwrap(); + page.expanded.insert(1); + page.step(&mut app, json!({"op": "enabled", "step": 1, "enabled": false})).unwrap(); + page.step(&mut app, json!({"op": "dialog", "step": 1, "enabled": true})).unwrap(); + page.step(&mut app, json!({"op": "delete", "step": 0})).unwrap(); + assert_eq!(page.selected, Some(0)); + assert!(page.expanded.contains(&0)); + page.step(&mut app, json!({"op": "duplicate", "step": 0})).unwrap(); + assert_eq!(page.selected, Some(1)); + page.step(&mut app, json!({"op": "move", "step": 1, "to": 0})).unwrap(); + assert_eq!(page.selected, Some(0)); + let view = page.get(&mut app).unwrap(); + assert_eq!(steps(&view).len(), 2); + for at in [0, 1] { + assert!(flagged(&view, "disabled", at)); + assert!(flagged(&view, "dialogs", at)); + assert_eq!(steps(&view)[at][1]["message"], "Read this stop"); + } + assert_eq!(app.session.actions.list, library); + assert_eq!(store(&mem), persisted); + assert_eq!(app.session.active().unwrap().doc, document); + assert_eq!(app.session.active().unwrap().history.past_len(), history); +} + +#[test] +fn stale_step_dialog_and_old_page_identity_cannot_retarget_new_content() { + let (mut app, _) = app(Default::default()); + let mut page = existing(&mut app); + let view = page.get(&mut app).unwrap(); + page.edit_settings(&view, 0); + page.editing.as_mut().unwrap().params.insert("name".into(), json!("Old dialog")); + let command = step_parts(&steps(&view)[0]).unwrap().0; + app.run("actions.editor.step", json!({"draft": page.draft(), "op": "replace", "step": 0, "id": command, "params": {"name": "External"}})).unwrap(); + let external = app.run("actions.editor.get", json!({"draft": page.draft()})).unwrap(); + assert!(page.accept_step(&mut app).is_err()); + assert!(page.editing.is_some()); + assert_eq!(app.run("actions.editor.get", json!({"draft": page.draft()})).unwrap(), external); + app.run("actions.editor.discard", json!({"draft": page.draft()})).unwrap(); + let fresh = app.run("actions.editor.open", json!({"action": "Original"})).unwrap(); + page.name.text = "Must not retarget".into(); + assert!(page.stage(&mut app).is_err()); + assert_eq!(app.run("actions.editor.get", json!({"draft": fresh["draft"]})).unwrap(), fresh); + assert_eq!(page.name.text, "Must not retarget"); +} + +#[test] +fn preview_is_an_owned_request_and_preserves_active_draft_capture() { + let (mut app, mem) = app(Default::default()); + let mut page = existing(&mut app); + let library = app.session.actions.list.clone(); + let persisted = store(&mem); + let document = app.session.active().unwrap().doc.clone(); + page.name.text = "Private preview name".into(); + page.call(&mut app, "record", json!({})).unwrap(); + assert_eq!(page.preview(&mut app, 0).unwrap(), Event::Preview { draft: page.draft(), from: 0 }); + assert_eq!(page.get(&mut app).unwrap()["recording"], true); + assert_eq!(app.session.actions.list, library); + assert_eq!(store(&mem), persisted); + assert_eq!(app.session.active().unwrap().doc, document); + assert!(page.request_leave(&mut app, Activity { playback_busy: true, finishing_step: false }).is_err()); + assert_eq!(page.get(&mut app).unwrap()["recording"], true); +} + +#[test] +fn suppression_persists_only_after_save_and_reopen_bypasses_both_prompts() { + let saved = Rc::new(RefCell::new(String::new())); + let sink = saved.clone(); + let (mut app, mem) = app(crate::Services { + save_prefs: Some(Box::new(move |text| { + *sink.borrow_mut() = text.to_string(); + Ok(()) + })), + ..Default::default() + }); + let mut page = existing(&mut app); + page.description.text = "Remembered Save".into(); + page.ask_save(&mut app, Activity::default()).unwrap(); + page.dont_show_again = true; + page.answer(&mut app, Answer::Save, Activity::default()).unwrap(); + assert!(Page::suppressed(&app)); + let text = saved.borrow().clone(); + assert!(text.contains(CONFIRM_PREF)); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem))).is_empty()); + let mut fresh = PhotocraftApp::new(session, crate::Services { load_prefs: Some(Box::new(move || Some(text.clone()))), ..Default::default() }); + assert!(Page::suppressed(&fresh)); + let mut page = existing(&mut fresh); + page.description.text = "No explicit Save prompt".into(); + assert!(matches!(page.ask_save(&mut fresh, Activity::default()).unwrap(), Some(Event::Saved { .. }))); + assert!(page.prompt.is_none()); + page.description.text = "No dirty navigation prompt".into(); + assert!(matches!(page.request_leave(&mut fresh, Activity::default()).unwrap(), Some(Event::Closed { saved: true, .. }))); + assert_eq!(fresh.session.actions.list[0].description, "No dirty navigation prompt"); +} + +#[test] +fn preference_write_failure_keeps_prompt_and_page_and_can_retry_without_resaving_definition() { + let (mut app, _) = app(crate::Services { save_prefs: Some(Box::new(|_| Err("preference write failed".into()))), ..Default::default() }); + let mut page = existing(&mut app); + page.description.text = "Saved definition".into(); + page.request_leave(&mut app, Activity::default()).unwrap(); + page.dont_show_again = true; + assert!(page.answer(&mut app, Answer::Save, Activity::default()).is_err()); + assert_eq!(page.prompt, Some(Intent::Leave)); + assert!(!page.closed); + assert_eq!(page.description.text, "Saved definition"); + assert_eq!(app.session.actions.list[0].description, "Saved definition"); + assert!(!Page::suppressed(&app)); + assert!(page.error.as_deref().unwrap().contains("preference write failed")); + let revision = app.session.actions.rev; + app.services.save_prefs = Some(Box::new(|_| Ok(()))); + assert!(matches!(page.answer(&mut app, Answer::Save, Activity::default()).unwrap(), Some(Event::Closed { saved: true, .. }))); + assert_eq!(app.session.actions.rev, revision); + assert!(Page::suppressed(&app)); +} + +#[test] +fn unfinished_capture_requires_an_explicit_leave_decision_and_cannot_save_early() { + let (mut app, _) = app(Default::default()); + let mut page = Page::open(&mut app, json!({})).unwrap(); + page.call(&mut app, "record", json!({})).unwrap(); + let activity = Activity { playback_busy: false, finishing_step: true }; + assert_eq!(page.request_leave(&mut app, activity).unwrap(), None); + assert_eq!(page.prompt, Some(Intent::Leave)); + assert!(page.answer(&mut app, Answer::Save, activity).is_err()); + assert_eq!(page.get(&mut app).unwrap()["recording"], true); + page.answer(&mut app, Answer::Cancel, activity).unwrap(); + assert_eq!(page.get(&mut app).unwrap()["recording"], true); +} + +#[test] +fn back_button_and_escape_route_through_the_same_owned_cancel_decision() { + use egui_kittest::{Harness, kittest::Queryable}; + struct State { + app: PhotocraftApp, + page: Option, + events: Vec, + } + let (mut app, _) = app(Default::default()); + let mut page = existing(&mut app); + page.description.text = "Keep unsent text".into(); + let library = app.session.actions.list.clone(); + let mut h = Harness::builder().with_size(egui::vec2(500.0, 800.0)).build_ui_state( + |ui, state: &mut State| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + let mut page = state.page.take().unwrap(); + let swatches = [Tokens::get(ui.ctx()).card; 12]; + if let Some(event) = page.show(&mut state.app, ui, &swatches, Activity::default()) { + state.events.push(event); + } + state.page = Some(page); + }, + State { app, page: Some(page), events: Vec::new() }, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h.get_by_label("Back").click(); + h.run_steps(3); + assert_eq!(h.state().page.as_ref().unwrap().prompt, Some(Intent::Leave)); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().events, vec![Event::CancelledLeave]); + assert_eq!(h.state().page.as_ref().unwrap().description.text, "Keep unsent text"); + assert_eq!(h.state().app.session.actions.list, library); +} diff --git a/crates/ui-egui/src/panels/actions/modern.rs b/crates/ui-egui/src/panels/actions/modern.rs new file mode 100644 index 00000000000..a72d0ad6b73 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/modern.rs @@ -0,0 +1,873 @@ +//! Modern Actions list and runtime editor integration. Definitions remain engine commands. +use super::*; +use crate::state::{ActionsLayout, ActionsListSize}; +use std::sync::Arc; + +#[derive(Default)] +pub(crate) struct Runtime { + page: Option, + page_drawn: Option, + pending: Option<(String, Value, Option)>, + task: Option, + progress: Option, + pub(super) quantum: Option, + pub(super) yielded: Option, + pub(super) parents: Vec, + delete: Option<(u64, Delete, Option)>, +} + +struct Task { + frame: Frame, + parents: Vec, + owner: Option, + gate: Option, + used: u64, + ran: u64, + then: Vec, +} + +struct Progress { + root: Arc, + completed: usize, + done: bool, +} + +enum Delete { + Action(Action), + Group(String, Vec, u64), +} + +pub(crate) fn editor_busy(app: &PhotocraftApp) -> bool { + app.actions_runtime.page.is_some() || app.session.actions.editor.is_some() || app.actions_runtime.task.is_some() || suspended(app) +} + +pub(crate) fn layout_checked(app: &PhotocraftApp, id: &str) -> Option { + match id { + "actions.layout.modern" => Some(app.ui.actions_layout == ActionsLayout::Modern), + "actions.layout.classic" => Some(app.ui.actions_layout == ActionsLayout::Classic), + _ => None, + } +} + +pub(crate) fn layout_command(app: &mut PhotocraftApp, id: &str) -> Option> { + let layout = match id { + "actions.layout.modern" => ActionsLayout::Modern, + "actions.layout.classic" => ActionsLayout::Classic, + _ => return None, + }; + if editor_busy(app) { + return Some(leave_for(app, id, json!({}))); + } + app.ui.actions_layout = layout; + Some(Ok(json!({"layout":layout}))) +} + +pub(crate) fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { + if ctx.input(|input| input.pointer.any_down()) { + return; + } + let saved = json!({"layout":app.ui.actions_layout,"size":app.ui.actions_list_size}); + if app.session.prefs().actions_panel != saved { + app.session.prefs.edit(|prefs| prefs.actions_panel = saved); + } +} + +pub(crate) fn restore(app: &mut PhotocraftApp) { + let saved = &app.session.prefs().actions_panel; + app.ui.actions_layout = match saved.get("layout").and_then(Value::as_str) { + Some("classic") => ActionsLayout::Classic, + _ => ActionsLayout::Modern, + }; + app.ui.actions_list_size = match saved.get("size").and_then(Value::as_str) { + Some("small") => ActionsListSize::Small, + _ => ActionsListSize::Large, + }; +} + +pub(crate) fn inspect(app: &PhotocraftApp) -> Value { + let page = app.actions_runtime.page.as_ref(); + json!({ + "layout":app.ui.actions_layout,"size":app.ui.actions_list_size,"search":app.ui.actions.search, + "selected":app.ui.actions.selected,"collapsedGroups":app.ui.actions.collapsed_sets, + "editor":page.map(|page| json!({"draft":page.draft(),"modal":page.has_modal(), + "value":actions_cmds::editor::inspect(&app.session,page.draft()).ok()})), + "playing":app.actions_runtime.progress.as_ref().map(|progress| json!({ + "name":progress.root.name,"progress":fraction(progress),"done":progress.done, + "paused":app.actions_runtime.task.is_none() && !progress.done, + })), + }) +} + +fn fraction(progress: &Progress) -> f32 { + if progress.done { + return 1.0; + } + let total = progress.root.enabled_steps().count(); + if total == 0 { 0.0 } else { (progress.completed as f32 / total as f32).min(0.99) } +} + +fn activity(app: &PhotocraftApp) -> editor::Activity { + editor::Activity { playback_busy: app.actions_runtime.task.is_some() || suspended(app), finishing_step: app.session.has_recording_job() } +} + +fn effective_gate(app: &PhotocraftApp) -> Option { + app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) +} + +fn attach(app: &mut PhotocraftApp) -> Result<(), String> { + if app.actions_runtime.page.is_none() + && let Some(draft) = app.session.actions.editor.as_ref() + { + let view = actions_cmds::editor::inspect(&app.session, draft.id()).map_err(|error| error.to_string())?; + app.actions_runtime.page = Some(editor::Page::attach(&view)?); + } + Ok(()) +} + +/// An explicit successful backend Discard also closes the matching visible control-channel +/// consumer. A page executing its own leave is extracted already and handles its own event. +pub(crate) fn discarded(app: &mut PhotocraftApp, draft: u64) { + if app.actions_runtime.page.as_ref().is_some_and(|page| page.draft() == draft) { + app.actions_runtime.page = None; + app.actions_runtime.pending = None; + } +} + +fn open(app: &mut PhotocraftApp, params: Value) -> Result { + if editor_busy(app) { + return Err(tl!("Close the open Actions editor first").into()); + } + let page = editor::Page::open(app, params)?; + let id = page.draft(); + app.actions_runtime.page = Some(page); + Ok(json!({"draft":id})) +} + +fn event(app: &mut PhotocraftApp, event: editor::Event) -> Result<(), String> { + match event { + editor::Event::Saved { source } | editor::Event::Closed { saved: true, source } => { + if let Some(index) = source { + app.ui.actions.select(index, None); + } + } + editor::Event::CancelledLeave => { + app.actions_runtime.pending = None; + } + editor::Event::Preview { draft, from } => { + start_preview(app, draft, from)?; + } + editor::Event::Closed { saved: false, .. } => {} + } + Ok(()) +} + +/// Defer a navigation effect until the owned page has saved or discarded successfully. +pub(super) fn leave_for(app: &mut PhotocraftApp, id: &str, params: Value) -> Result { + if app.actions_runtime.pending.is_some() { + return Err(tl!("Answer the open Actions editor question first").into()); + } + attach(app)?; + let active = activity(app); + if active.playback_busy || active.finishing_step { + return Err(tl!("Finish or cancel action playback and the pending step first").into()); + } + let Some(mut page) = app.actions_runtime.page.take() else { return Err(tl!("Close the open Actions editor first").into()) }; + let previous = app.actions_runtime.pending.take(); + app.actions_runtime.pending = Some((id.to_string(), params, app.session.authorize)); + match page.request_leave(app, active) { + Ok(Some(closed @ editor::Event::Closed { .. })) => { + event(app, closed)?; + finish_navigation(app)?; + } + Ok(Some(other)) => { + event(app, other)?; + app.actions_runtime.page = Some(page); + } + Ok(None) => { + app.actions_runtime.page = Some(page); + } + Err(error) => { + app.actions_runtime.page = Some(page); + app.actions_runtime.pending = previous; + return Err(error); + } + } + Ok(json!({"pending":app.actions_runtime.pending.is_some()})) +} + +fn finish_navigation(app: &mut PhotocraftApp) -> Result<(), String> { + let Some((id, params, gate)) = app.actions_runtime.pending.take() else { return Ok(()) }; + let saved = app.session.authorize; + app.session.authorize = gate.or(saved); + let result = if id == "__actionsExit" { + app.actions_runtime.pending = Some((id, params, gate)); + Ok(Value::Null) + } else { + app.run(&id, params) + }; + app.session.authorize = saved; + result.map(|_| ()) +} + +pub(super) fn layout_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + for (id, label) in [("actions.layout.modern", "Actions"), ("actions.layout.classic", "Classic Actions")] { + if ui.add_enabled(!editor_busy(app), egui::RadioButton::new(layout_checked(app, id) == Some(true), tl!(label))).clicked() { + ui.close(); + let result = app_run_layout(app, id); + report(app, result); + } + } +} + +fn app_run_layout(app: &mut PhotocraftApp, id: &str) -> Result { + app.run(id, json!({})) +} +fn report(app: &mut PhotocraftApp, result: Result) { + if let Err(error) = result + && error != crate::file_dialog::CANCELLED + { + app.ui.status = error; + app.ui.status_error = true; + } +} + +pub(super) fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.add_enabled(false, egui::Button::new(tl!("Preview on hover"))); + let mut command = None; + if ui.button(tl!("Import Actions…")).clicked() { + command = Some("import"); + } + let selected = app.ui.actions.selected.is_some_and(|index| index < app.session.actions.list.len()); + if ui.add_enabled(selected, egui::Button::new(tl!("Export Action…"))).clicked() { + command = Some("export"); + } + for label in ["Clear All Actions", "Playback Options…", "Allow Tool Recording"] { + ui.add_enabled(false, egui::Button::new(tl!(label))); + } + layout_menu(app, ui); + ui.separator(); + if ui.button(tl!("Close")).clicked() { + command = Some("close"); + } + if ui.button(tl!("Close Tab Group")).clicked() { + command = Some("closeGroup"); + } + if let Some(command) = command { + ui.close(); + let result = match command { + "import" => load_dialog(app), + "export" => export(app), + "close" => app.run("window.panel.close", json!({"panel":"actions"})), + _ => app.run("window.panel.closeGroup", json!({"panel":"actions"})), + }; + report(app, result); + } +} + +fn export(app: &mut PhotocraftApp) -> Result { + let index = app.ui.actions.selected.ok_or("Select an action to export")?; + let action = app.session.actions.list.get(index).ok_or("The selected action no longer exists")?; + let stem = action.name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + let mut stem = photocraft_engine::file_cmds::portable_device_name(stem); + while stem.len() > 251 { + stem.pop(); + } + let result = app.run("actions.save", json!({"action":index,"skipUnsupported":true}))?; + let bytes = result["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("The action export returned no data")?; + app.pick_save(&format!("{stem}.ATN"), move |app, path| { + app.services.write.as_mut().ok_or("No writer configured")?(&path, &bytes)?; + Ok(json!({"path":path})) + }) +} + +#[derive(Clone)] +struct DragAction { + index: usize, + source: Action, +} + +enum Operation { + New, + Edit(usize), + Play(Vec), + Duplicate(Value), + DeleteAction(usize), + DeleteGroup(String), + Assign(DragAction, String), + NewGroup, +} + +fn context(ui: &mut egui::Ui, action: Option, group: Option<&str>, play: Vec) -> Option { + let mut operation = None; + if ui.add_enabled(action.is_some(), egui::Button::new(tl!("Edit Action"))).clicked() { + operation = action.map(Operation::Edit); + } + if ui.button(if group.is_some() { tl!("Play Actions") } else { tl!("Play Action") }).clicked() { + operation = Some(Operation::Play(play)); + } + for label in ["Apply to subject", "Apply to background"] { + ui.add_enabled(false, egui::Button::new(tl!(label))); + } + let duplicate = if group.is_some() { tl!("Duplicate group") } else { tl!("Duplicate action") }; + if ui.button(duplicate).clicked() { + operation = Some(Operation::Duplicate(match group { + Some(group) => json!({"set":group}), + None => json!({"action":action}), + })); + } + let delete = if group.is_some() { tl!("Delete group") } else { tl!("Delete action") }; + if ui.button(delete).clicked() { + operation = match group { + Some(group) => Some(Operation::DeleteGroup(group.into())), + None => action.map(Operation::DeleteAction), + }; + } + if operation.is_some() { + ui.close(); + } + operation +} + +fn thumbnail(ui: &mut egui::Ui, action: &Action, size: f32, tokens: &Tokens) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + let color = actions_cmds::BUTTON_COLORS.iter().position(|name| *name == action.color).filter(|index| *index > 0); + let fill = color.and_then(|index| tokens.action_swatches().get(index).copied()).unwrap_or(tokens.card); + ui.painter().rect_filled(rect, tokens.radius_sm, fill); + let glyph = if action.icon.is_empty() { "action-editor-click" } else { editor_glyphs::display_glyph(&action.icon) }; + icons::paint(ui, rect, glyph, size * 0.6, if color.is_some() { Color32::WHITE } else { tokens.text }); + response +} + +fn action_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, index: usize, action: &Action, indented: bool) -> Option { + let tokens = Tokens::get(ui.ctx()); + let size = if app.ui.actions_list_size == ActionsListSize::Large { 40.0 } else { 28.0 }; + let hovered = ui.rect_contains_pointer(Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), size))); + let mut operation = None; + let row = ui.horizontal(|ui| { + if indented { + ui.add_space(16.0); + } + let tile = thumbnail(ui, action, size, &tokens); + if tile.clicked() { + app.ui.actions.select(index, None); + operation = Some(Operation::Play(vec![index])); + } + if tile.drag_started() { + tile.dnd_set_drag_payload(DragAction { index, source: action.clone() }); + } + let name = ui + .add_sized(vec2((ui.available_width() - 60.0).max(20.0), size), egui::Button::new(&action.name).frame(false).sense(Sense::click_and_drag())) + .on_hover_text(&action.name); + if name.clicked() { + app.ui.actions.select(index, None); + operation = Some(Operation::Play(vec![index])); + } + if name.drag_started() { + name.dnd_set_drag_payload(DragAction { index, source: action.clone() }); + } + if hovered || app.ui.actions.selected == Some(index) { + if icons::button(ui, "settings", 24.0, false, tl!("Edit action")).clicked() { + operation = Some(Operation::Edit(index)); + } + } + let menu = icons::button(ui, "ellipsis", 24.0, false, tl!("Actions")); + egui::Popup::menu(&menu).show(|ui| { + operation = context(ui, Some(index), None, vec![index]).or(operation.take()); + }); + }); + row.response.context_menu(|ui| { + operation = context(ui, Some(index), None, vec![index]).or(operation.take()); + }); + operation +} + +pub(super) fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if let Err(error) = attach(app) { + report(app, Err(error)); + } + if let Some(progress) = &app.actions_runtime.progress { + ui.heading(&progress.root.name); + ui.add(egui::ProgressBar::new(fraction(progress)).show_percentage()); + if !progress.done && ui.button(tl!("Stop")).clicked() { + cancel(app); + } + return; + } + if let Some(mut page) = app.actions_runtime.page.take() { + app.actions_runtime.page_drawn = Some(app.frame); + let active = activity(app); + let swatches = Tokens::get(ui.ctx()).action_swatches(); + let answer = page.show(app, ui, &swatches, active); + let closed = matches!(answer, Some(editor::Event::Closed { .. })); + if !closed { + app.actions_runtime.page = Some(page); + } + if let Some(answer) = answer { + let result = event(app, answer).and_then(|()| if closed { finish_navigation(app) } else { Ok(()) }); + report(app, result.map(|()| Value::Null)); + } + return; + } + let mut operation = None; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut app.ui.actions.search).hint_text(tl!("Search actions")).desired_width((ui.available_width() - 28.0).max(40.0))) + .accessible_name(tl!("Search actions")); + if ui.button("×").accessible_name(tl!("Clear search")).on_hover_text(tl!("Clear search")).clicked() { + app.ui.actions.search.clear(); + } + }); + ui.horizontal(|ui| { + ui.strong(tl!("Yours")); + let view = icons::button(ui, "ellipsis", 24.0, false, tl!("Sort & View options")); + egui::Popup::menu(&view).show(|ui| { + ui.label(tl!("List size")); + ui.radio_value(&mut app.ui.actions_list_size, ActionsListSize::Large, tl!("Large")); + ui.radio_value(&mut app.ui.actions_list_size, ActionsListSize::Small, tl!("Small")); + }); + if icons::button(ui, "folder-plus", 24.0, false, tl!("Create new group")).clicked() { + operation = Some(Operation::NewGroup); + } + if icons::button(ui, "plus", 24.0, false, tl!("Create new action")).clicked() { + operation = Some(Operation::New); + } + }); + let list = app.session.actions.list.clone(); + let query = app.ui.actions.search.to_lowercase(); + let matches = |action: &Action| action.name.to_lowercase().contains(&query); + let groups = photocraft_engine::actions_edit_cmds::set_names(&app.session); + egui::ScrollArea::vertical().id_salt("modern-actions-list").show(ui, |ui| { + for group in groups.iter().filter(|group| group.as_str() != photocraft_engine::atn_cmds::UNGROUPED_SET) { + let members: Vec<_> = list + .iter() + .enumerate() + .filter(|(_, action)| photocraft_engine::atn_cmds::logical_set_name(action.set.as_deref()) == group) + .map(|(index, _)| index) + .collect(); + if !query.is_empty() && !members.iter().any(|&index| matches(&list[index])) { + continue; + } + let collapsed = app.ui.actions.collapsed_sets.contains(group); + let header = ui.horizontal(|ui| { + if icons::button(ui, if collapsed { "chevron-right" } else { "chevron-down" }, 20.0, false, group).clicked() { + if collapsed { + app.ui.actions.collapsed_sets.retain(|name| name != group); + } else { + app.ui.actions.collapsed_sets.push(group.clone()); + } + } + ui.strong(group); + let menu = icons::button(ui, "ellipsis", 24.0, false, tl!("Actions")); + egui::Popup::menu(&menu).show(|ui| { + operation = context(ui, None, Some(group), members.clone()).or(operation.take()); + }); + }); + if let Some(drag) = header.response.dnd_release_payload::() { + operation = Some(Operation::Assign((*drag).clone(), group.clone())); + } + header.response.context_menu(|ui| { + operation = context(ui, None, Some(group), members.clone()).or(operation.take()); + }); + if !collapsed || !query.is_empty() { + for &index in &members { + if matches(&list[index]) { + operation = action_row(app, ui, index, &list[index], true).or(operation.take()); + } + } + } + } + let ungrouped: Vec<_> = list + .iter() + .enumerate() + .filter(|(_, action)| photocraft_engine::atn_cmds::logical_set_name(action.set.as_deref()) == photocraft_engine::atn_cmds::UNGROUPED_SET) + .collect(); + let empty_ungrouped = ungrouped.is_empty() && groups.iter().any(|group| group == photocraft_engine::atn_cmds::UNGROUPED_SET); + if !ungrouped.is_empty() || empty_ungrouped { + ui.separator(); + } + for (index, action) in ungrouped { + if matches(action) { + operation = action_row(app, ui, index, action, false).or(operation.take()); + } + } + if empty_ungrouped { + // No ordinary Ungrouped header: this row represents a real retained empty set. + let header = ui.horizontal(|ui| { + ui.strong(set_label(photocraft_engine::atn_cmds::UNGROUPED_SET)); + let menu = icons::button(ui, "ellipsis", 24.0, false, tl!("Actions")); + egui::Popup::menu(&menu).show(|ui| { + operation = context(ui, None, Some(photocraft_engine::atn_cmds::UNGROUPED_SET), Vec::new()).or(operation.take()); + }); + }); + if let Some(drag) = header.response.dnd_release_payload::() { + operation = Some(Operation::Assign((*drag).clone(), photocraft_engine::atn_cmds::UNGROUPED_SET.to_string())); + } + } + if list.is_empty() { + let tokens = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 70.0), Sense::hover()); + icons::paint(ui, rect, "action-editor-click", 36.0, tokens.text_faint); + ui.label(tl!("You haven't created any actions")); + ui.label(tl!("Import actions or create a new action to get started.")); + } + }); + if let Some(operation) = operation { + let result = match operation { + Operation::New => { + let set = app.ui.actions.selected_set.clone(); + open(app, json!({"set":set})) + } + Operation::Edit(index) => open(app, json!({"action":index})), + Operation::Play(indices) => start_library(app, &indices), + Operation::Duplicate(params) => app.run("actions.duplicate", params), + Operation::DeleteAction(index) => ask_delete(app, Some(index), None), + Operation::DeleteGroup(group) => ask_delete(app, None, Some(group)), + Operation::Assign(drag, group) => { + if app.session.actions.list.get(drag.index) != Some(&drag.source) { + Err(tl!("The dragged action changed; drag it again").into()) + } else { + app.run("actions.assignSet", json!({"action":drag.index,"set":group})) + } + } + Operation::NewGroup => new_group(app), + }; + report(app, result); + } +} + +fn new_group(app: &mut PhotocraftApp) -> Result { + let names = photocraft_engine::actions_edit_cmds::set_names(&app.session); + let name = (1u32..).map(|number| format!("Group {number}")).find(|name| !names.contains(name)).ok_or("No free group name")?; + let fields = json!({NEW_SET:true,"__command":"actions.newSet","__label":"New group","name":name}); + let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Ok(json!({"dialog":id})) +} + +fn start_preview(app: &mut PhotocraftApp, draft: u64, from: usize) -> Result { + let (action, owner) = actions_cmds::playback::preview(&mut app.session, draft, from).map_err(|error| error.to_string())?; + let frame = Frame { action, from, source: Source::Draft(draft), take: app.session.actions.take }; + queue(app, frame, owner, Vec::new()) +} + +fn start_library(app: &mut PhotocraftApp, indices: &[usize]) -> Result { + let Some((&index, rest)) = indices.split_first() else { return Ok(json!({"ran":0})) }; + let then = rest + .iter() + .map(|&index| app.session.actions.list.get(index).cloned().ok_or("The selected action no longer exists".to_string())) + .collect::, _>>()?; + let (action, from, owner) = actions_cmds::playback::Owner::library(&mut app.session, &json!({"action":index})).map_err(|error| error.to_string())?; + let frame = Frame { action, from, source: Source::Library, take: app.session.actions.take }; + queue(app, frame, owner, then) +} + +fn queue(app: &mut PhotocraftApp, frame: Frame, owner: actions_cmds::playback::Owner, then: Vec) -> Result { + if app.actions_runtime.task.is_some() { + owner.finish(&mut app.session); + return Err("Another action is already playing".into()); + } + if let Err(error) = owner.yielding(&mut app.session, true) { + owner.finish(&mut app.session); + return Err(error.to_string()); + } + app.actions_runtime.progress = Some(Progress { root: frame.action.clone(), completed: 0, done: false }); + app.actions_runtime.task = Some(Task { frame, parents: Vec::new(), owner: Some(owner), gate: effective_gate(app), used: 0, ran: 0, then }); + Ok(json!({"playing":true})) +} + +pub(super) fn resume_tick(app: &mut PhotocraftApp, paused: PausedPlayback) -> Result<(), String> { + if let Some(owner) = &paused.record + && let Err(error) = owner.yielding(&mut app.session, true) + { + if let Some(owner) = paused.record { + owner.finish(&mut app.session); + } + app.actions_runtime.progress = None; + return Err(error.to_string()); + } + app.actions_runtime.task = Some(Task { + frame: paused.frame, + parents: paused.parents, + owner: paused.record, + gate: paused.gate, + used: paused.used, + ran: paused.ran.saturating_add(1), + then: paused.then, + }); + Ok(()) +} + +pub(crate) fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { + tick_with_quantum(app, ctx, 8); +} + +fn tick_with_quantum(app: &mut PhotocraftApp, ctx: &egui::Context, quantum: usize) { + if app.actions_runtime.page.is_none() && app.actions_runtime.pending.as_ref().is_some_and(|(id, _, _)| id == "__actionsExit") { + app.actions_runtime.pending = None; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + let orphaned: Vec<_> = app + .paused_playbacks + .paused + .iter() + .filter_map(|(token, paused)| (paused.incremental && app.ui.dialogs.iter().all(|dialog| dialog.id != paused.dialog)).then_some(*token)) + .collect(); + for token in orphaned { + abandoned(app, &serde_json::Map::from_iter([(RESUME.into(), json!(token))])); + } + let Some(mut task) = app.actions_runtime.task.take() else { + if app.actions_runtime.progress.as_ref().is_some_and(|progress| progress.done) { + app.actions_runtime.progress = None; + } + return; + }; + let saved = app.session.authorize; + app.session.authorize = task.gate.or(saved); + app.actions_runtime.quantum = Some(quantum.max(1)); + app.actions_runtime.yielded = None; + app.actions_runtime.parents.clear(); + let result = (|| { + let owner = task.owner.as_ref().ok_or("The playback lost its owner")?; + owner.yielding(&mut app.session, false).map_err(|error| error.to_string())?; + if let Source::Draft(id) = task.frame.source + && owner.draft_id() != Some(id) + { + return Err("The draft preview lost its owner".into()); + } + task.frame.validate(&app.session)?; + run_with_parents(app, task.frame.clone(), &task.parents, task.used, true) + })(); + app.actions_runtime.quantum = None; + app.session.authorize = saved; + task.used = app.session.actions.playback_steps; + let result = result.and_then(|value| { + task.ran = task.ran.saturating_add(value.get("ran").and_then(Value::as_u64).unwrap_or(0)); + if let Some(failed) = value.get("failed") { + return Err(failed.get("error").and_then(Value::as_str).unwrap_or("Action playback failed").to_string()); + } + if let Some(dialog) = value.get("paused").and_then(|paused| paused.get("dialog")).and_then(Value::as_u64) { + let token = app.ui.dialog_mut(dialog).and_then(|dialog| dialog.fields.get(RESUME)).and_then(Value::as_u64); + let entry = token.and_then(|token| app.paused_playbacks.paused.get_mut(&token)).ok_or("The action step dialog lost its continuation")?; + entry.parents.extend(std::mem::take(&mut task.parents)); + entry.used = task.used; + entry.ran = task.ran; + entry.record = task.owner.take(); + entry.incremental = true; + entry.then = std::mem::take(&mut task.then); + return Ok(false); + } + if let Some(frame) = app.actions_runtime.yielded.take() { + let mut parents = std::mem::take(&mut app.actions_runtime.parents); + parents.append(&mut task.parents); + task.parents = parents; + task.frame = frame; + } else if !task.parents.is_empty() { + task.frame = task.parents.remove(0); + } else { + return Ok(true); + } + if let Some(progress) = &mut app.actions_runtime.progress { + let root_frame = task.parents.last().unwrap_or(&task.frame); + let frontier = if task.parents.is_empty() { root_frame.from } else { root_frame.from.saturating_sub(1) }; + progress.completed = progress.root.enabled_steps().filter(|(index, _)| *index < frontier).count(); + } + task.owner.as_ref().ok_or("The playback lost its owner")?.yielding(&mut app.session, true).map_err(|error| error.to_string())?; + Ok(false) + }); + match result { + Ok(false) if task.owner.is_some() => { + app.actions_runtime.task = Some(task); + ctx.request_repaint(); + } + Ok(false) => { + ctx.request_repaint(); + } + Ok(true) => { + if let Some(owner) = task.owner.take() { + owner.finish(&mut app.session); + } + if task.then.is_empty() { + if let Some(progress) = &mut app.actions_runtime.progress { + progress.done = true; + } + ctx.request_repaint(); + } else { + let indices: Option> = task.then.iter().map(|action| app.session.actions.list.iter().position(|current| current == action)).collect(); + let result = indices.ok_or("A group action changed during playback".to_string()).and_then(|indices| start_library(app, &indices)); + if result.is_err() { + app.actions_runtime.progress = None; + } + report(app, result); + ctx.request_repaint(); + } + } + Err(error) => { + if let Some(owner) = task.owner.take() { + owner.finish(&mut app.session); + } + app.actions_runtime.progress = None; + report(app, Err(error)); + ctx.request_repaint(); + } + } +} + +pub(super) fn cancel(app: &mut PhotocraftApp) { + if let Some(mut task) = app.actions_runtime.task.take() + && let Some(owner) = task.owner.take() + { + owner.finish(&mut app.session); + } + let tokens: Vec<_> = app.paused_playbacks.paused.iter().filter_map(|(token, paused)| paused.incremental.then_some(*token)).collect(); + for token in tokens { + if let Some(paused) = app.paused_playbacks.paused.remove(&token) { + app.ui.close_dialog(paused.dialog); + if let Some(owner) = paused.record { + owner.finish(&mut app.session); + } + } + } + app.actions_runtime.progress = None; + app.actions_runtime.yielded = None; + app.actions_runtime.parents.clear(); +} + +pub(crate) fn shutdown(app: &mut PhotocraftApp) { + cancel(app); + for (_, paused) in std::mem::take(&mut app.paused_playbacks.paused) { + if let Some(owner) = paused.record { + owner.finish(&mut app.session); + } + } +} + +pub(crate) fn guard_navigation(app: &mut PhotocraftApp, id: &str, params: Value) -> Option> { + editor_busy(app).then(|| leave_for(app, id, params)) +} + +pub(crate) fn guard_exit(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + if !editor_busy(app) { + return false; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if app.actions_runtime.pending.is_none() { + let result = leave_for(app, "__actionsExit", Value::Null); + report(app, result); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + true +} + +pub(crate) fn modals(app: &mut PhotocraftApp, ctx: &egui::Context) { + if app.actions_runtime.page_drawn == Some(app.frame) { + return; + } + let Some(mut page) = app.actions_runtime.page.take() else { return }; + let active = activity(app); + let answer = page.show_modals(app, ctx, active); + let closed = matches!(answer, Some(editor::Event::Closed { .. })); + if !closed { + app.actions_runtime.page = Some(page); + } + if let Some(answer) = answer { + let result = event(app, answer).and_then(|()| if closed { finish_navigation(app) } else { Ok(()) }); + report(app, result.map(|()| Value::Null)); + } +} + +const DELETE: &str = "__actionsModernDelete"; + +pub(crate) fn owns_delete(fields: &serde_json::Map) -> bool { + fields.contains_key(DELETE) +} + +fn ask_delete(app: &mut PhotocraftApp, index: Option, group: Option) -> Result { + if app.actions_runtime.delete.is_some() { + return Err("Answer the open delete question first".into()); + } + if editor_busy(app) || app.session.actions.recording_active() { + return Err("Stop recording and close the Actions editor first".into()); + } + let (target, message) = match group { + Some(group) => { + if !photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(&group) { + return Err("The selected group no longer exists".into()); + } + let members = app + .session + .actions + .list + .iter() + .filter(|action| photocraft_engine::atn_cmds::logical_set_name(action.set.as_deref()) == group) + .cloned() + .collect(); + let message = crate::i18n::fmt(tl!("Delete the group \"{name}\"?"), &[("name", &group)]); + (Delete::Group(group, members, app.session.actions.rev), message) + } + None => { + let action = index.and_then(|index| app.session.actions.list.get(index)).cloned().ok_or("The selected action no longer exists")?; + let message = crate::i18n::fmt(tl!("Delete the action \"{name}\"?"), &[("name", &action.name)]); + (Delete::Action(action), message) + } + }; + let fields = json!({"__label":"Delete action","message":message}); + let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + if let Some(dialog) = app.ui.dialog_mut(id) { + dialog.fields.insert(DELETE.into(), json!(id)); + } + app.actions_runtime.delete = Some((id, target, effective_gate(app))); + Ok(json!({"dialog":id})) +} + +pub(crate) fn delete_body(ui: &mut egui::Ui, fields: &mut serde_json::Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); +} + +pub(crate) fn delete_cancel(app: &mut PhotocraftApp, fields: &serde_json::Map) { + if app.actions_runtime.delete.as_ref().is_some_and(|(id, _, _)| fields.get(DELETE).and_then(Value::as_u64) == Some(*id)) { + app.actions_runtime.delete = None; + } +} + +pub(crate) fn delete_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result { + let (id, target, gate) = app.actions_runtime.delete.take().ok_or("The delete question is no longer current")?; + if fields.get(DELETE).and_then(Value::as_u64) != Some(id) { + return Err("The delete question has changed".into()); + } + let params = match target { + Delete::Action(action) => { + let mut matches = app.session.actions.list.iter().enumerate().filter(|(_, current)| *current == &action).map(|(index, _)| index); + let index = matches.next().ok_or("The selected action changed; select it again")?; + if matches.next().is_some() { + return Err("The selected action is ambiguous".into()); + } + json!({"action":index}) + } + Delete::Group(group, members, revision) => { + let current: Vec<_> = app + .session + .actions + .list + .iter() + .filter(|action| photocraft_engine::atn_cmds::logical_set_name(action.set.as_deref()) == group) + .cloned() + .collect(); + if app.session.actions.rev != revision || current != members || !photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(&group) { + return Err("The selected group changed; select it again".into()); + } + json!({"set":group}) + } + }; + let saved = app.session.authorize; + app.session.authorize = gate.or(saved); + let result = app.run("actions.delete", params); + app.session.authorize = saved; + if result.is_ok() { + app.ui.actions.selected = None; + app.ui.actions.selected_step = None; + app.ui.actions.selected_set = None; + } + result +} + +#[cfg(test)] +#[path = "new_layout_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/panels/actions/new_layout_tests.rs b/crates/ui-egui/src/panels/actions/new_layout_tests.rs new file mode 100644 index 00000000000..03c42d47577 --- /dev/null +++ b/crates/ui-egui/src/panels/actions/new_layout_tests.rs @@ -0,0 +1,326 @@ +//! Modern presentation and its actual runtime ownership/navigation seams. +use super::*; +use egui_kittest::{Harness, kittest::Queryable}; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":8,"height":6,"background":"#203040"})).unwrap(); + app +} + +fn draft(app: &mut PhotocraftApp, steps: &[(&str, Value)]) -> u64 { + let id = open(app, json!({"name":"Draft"})).unwrap()["draft"].as_u64().unwrap(); + for (index, (command, params)) in steps.iter().enumerate() { + app.run("actions.editor.step", json!({"draft":id,"op":"insert","step":index,"id":command,"params":params})).unwrap(); + } + id +} + +fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(480.0, 740.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + panel(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h +} + +#[test] +fn defaults_layout_commands_and_validated_preferences_are_shell_only() { + let mut app = app(); + assert_eq!(app.ui.actions_layout, ActionsLayout::Modern); + assert_eq!(app.ui.actions_list_size, ActionsListSize::Large); + let actions = app.session.actions.clone(); + app.run("actions.layout.classic", json!({})).unwrap(); + app.ui.actions_list_size = ActionsListSize::Small; + persist(&mut app, &egui::Context::default()); + assert_eq!(app.session.prefs().actions_panel, json!({"layout":"classic","size":"small"})); + assert_eq!(app.session.actions, actions); + assert_eq!(layout_checked(&app, "actions.layout.classic"), Some(true)); + assert!(photocraft_engine::shell_commands::SHELL_COMMANDS.iter().any(|entry| entry.0 == "actions.layout.modern")); + let mut fresh = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + fresh.session.prefs.edit(|prefs| prefs.actions_panel = json!({"layout":"classic","size":"small","draft":99})); + restore(&mut fresh); + assert_eq!(fresh.ui.actions_layout, ActionsLayout::Classic); + assert_eq!(fresh.ui.actions_list_size, ActionsListSize::Small); + assert!(fresh.actions_runtime.page.is_none()); + for bad in [Value::Null, json!([]), json!({"layout":"future","size":-1})] { + fresh.session.prefs.edit(|prefs| prefs.actions_panel = bad); + restore(&mut fresh); + assert_eq!(fresh.ui.actions_layout, ActionsLayout::Modern); + assert_eq!(fresh.ui.actions_list_size, ActionsListSize::Large); + } +} + +#[test] +fn modern_empty_page_has_search_creation_and_required_panel_menu() { + let mut h = harness(app()); + h.get_by_label("Search actions"); + h.get_by_label("Yours"); + h.get_by_label("You haven't created any actions"); + h.get_by_label("Create new action").click(); + h.run_steps(3); + assert!(h.state().actions_runtime.page.is_some()); + assert!(h.state().session.actions.list.is_empty(), "New opens an unpublished draft"); + assert_eq!(inspect(h.state())["layout"], "modern"); +} + +#[test] +fn dirty_layout_leave_cancel_keeps_page_and_capture_then_discard_resumes_destination() { + let mut app = app(); + let id = draft(&mut app, &[("select.all", json!({}))]); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + let take = app.session.actions.take; + app.run("actions.layout.classic", json!({})).unwrap(); + assert!(app.actions_runtime.pending.is_some()); + let mut h = harness(app); + h.get_by_label("Cancel and continue editing").click(); + h.run_steps(3); + assert!(h.state().actions_runtime.pending.is_none()); + assert_eq!(h.state().actions_runtime.page.as_ref().unwrap().draft(), id); + assert!(h.state().session.actions.recording_active()); + assert_eq!(h.state().session.actions.take, take); + h.state_mut().run("actions.layout.classic", json!({})).unwrap(); + h.run_steps(3); + h.get_by_label("Discard").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions_layout, ActionsLayout::Classic); + assert!(h.state().actions_runtime.page.is_none()); + assert!(h.state().session.actions.editor.is_none()); + assert!(h.state().session.actions.list.is_empty()); +} + +#[test] +fn delete_confirmation_is_one_use_and_rejects_a_changed_target() { + let mut app = app(); + app.session.actions.list.push(Action::new("Delete me", Vec::new())); + let id = ask_delete(&mut app, Some(0), None).unwrap()["dialog"].as_u64().unwrap(); + app.session.actions.list[0].description = "Changed after the question".into(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert_eq!(app.session.actions.list.len(), 1); + assert!(app.actions_runtime.delete.is_none()); + let id = ask_delete(&mut app, Some(0), None).unwrap()["dialog"].as_u64().unwrap(); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert_eq!(app.session.actions.list.len(), 1); + let id = ask_delete(&mut app, Some(0), None).unwrap()["dialog"].as_u64().unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.actions.list.is_empty()); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); +} + +#[test] +fn empty_group_delete_question_rejects_a_changed_library_revision() { + let mut app = app(); + app.run("actions.newSet", json!({"name":"Empty"})).unwrap(); + let id = ask_delete(&mut app, None, Some("Empty".into())).unwrap()["dialog"].as_u64().unwrap(); + app.run("actions.newSet", json!({"name":"Later"})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(photocraft_engine::actions_edit_cmds::set_names(&app.session).contains(&"Empty".to_string())); +} + +#[test] +fn forced_small_ticks_show_progress_keep_parent_tail_budget_gate_and_capture() { + let mut app = app(); + app.session.actions.list.push(Action::new("Child", vec![("layer.new.layer".into(), json!({"name":"Child one"})), ("select.all".into(), json!({}))])); + let id = draft(&mut app, &[("actions.play", json!({"action":"Child"})), ("layer.new.layer", json!({"name":"Parent tail"}))]); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + let before = actions_cmds::editor::inspect(&app.session, id).unwrap()["action"].clone(); + let take = app.session.actions.take; + start_preview(&mut app, id, 0).unwrap(); + assert!(app.session.execute("actions.editor.update", json!({"draft":id,"name":"Intruder"})).is_err()); + assert!(app.session.start("filter.blur.gaussianBlur", json!({"radius":1})).is_err()); + let ctx = egui::Context::default(); + for _ in 0..3 { + tick_with_quantum(&mut app, &ctx, 1); + } + assert_eq!(inspect(&app)["playing"]["progress"], 0.5); + assert_eq!(app.actions_runtime.task.as_ref().unwrap().used, 3); + let mut h = harness(app); + h.get_by_label("Draft"); // The real panel displays the active action, not the list. + assert_eq!(inspect(h.state())["playing"]["progress"], 0.5); + tick_with_quantum(h.state_mut(), &ctx, 1); + assert_eq!(inspect(h.state())["playing"]["progress"], 1.0); + assert!(h.state().session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == "Parent tail")); + assert_eq!(h.state().session.actions.take, take); + assert!(h.state().session.actions.recording_active()); + assert_eq!(actions_cmds::editor::inspect(&h.state().session, id).unwrap()["action"], before); + assert!(h.state().session.actions.held_playback.is_none()); + + fn deny(id: &str, _: &Value) -> photocraft_engine::Result<()> { + if id == "layer.new.layer" { Err(photocraft_engine::EngineError::BadParams { cmd: id.into(), msg: "original gate".into() }) } else { Ok(()) } + } + tick_with_quantum(h.state_mut(), &ctx, 1); // Release the completed card. + h.state_mut().automation_input = true; + h.state_mut().services.automation_authorize = Some(deny); + start_preview(h.state_mut(), id, 0).unwrap(); + h.state_mut().automation_input = false; + tick_with_quantum(h.state_mut(), &ctx, 1); + tick_with_quantum(h.state_mut(), &ctx, 1); + assert!(h.state().ui.status.contains("original gate")); + assert!(h.state().session.actions.recording_active()); + assert!(h.state().session.actions.held_playback.is_none()); + + h.state_mut().services.automation_authorize = None; + start_preview(h.state_mut(), id, 0).unwrap(); + h.state_mut().actions_runtime.task.as_mut().unwrap().used = actions_cmds::MAX_PLAYBACK_STEPS - 1; + tick_with_quantum(h.state_mut(), &ctx, 1); + tick_with_quantum(h.state_mut(), &ctx, 1); + assert!(h.state().ui.status.contains("step limit")); + assert!(h.state().session.actions.recording_active()); + assert!(h.state().session.actions.held_playback.is_none()); +} + +#[test] +fn tick_step_dialog_confirm_cancel_and_replayed_token_finish_ownership_once() { + let mut app = app(); + let id = draft(&mut app, &[("filter.blur.gaussianBlur", json!({"radius":1})), ("layer.new.layer", json!({"name":"After dialog"}))]); + app.run("actions.editor.step", json!({"draft":id,"op":"dialog","step":0,"enabled":true})).unwrap(); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + let ctx = egui::Context::default(); + start_preview(&mut app, id, 0).unwrap(); + tick_with_quantum(&mut app, &ctx, 1); + let dialog = app.ui.dialogs.last().unwrap().id; + let fields = app.ui.dialogs.last().unwrap().fields.clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.actions_runtime.task.is_some()); + tick_with_quantum(&mut app, &ctx, 1); + assert!(app.session.actions.recording_active()); + assert!(resume(&mut app, &fields).is_err()); + assert!(app.session.actions.recording_active()); + tick_with_quantum(&mut app, &ctx, 1); + start_preview(&mut app, id, 0).unwrap(); + tick_with_quantum(&mut app, &ctx, 1); + let dialog = app.ui.dialogs.last().unwrap().id; + crate::dialogs::cancel(&mut app, dialog).unwrap(); + assert!(app.actions_runtime.progress.is_none()); + assert!(app.session.actions.recording_active()); + assert!(app.session.actions.held_playback.is_none()); +} + +#[test] +fn runtime_shutdown_releases_an_owned_yield_without_saving_the_draft() { + let mut app = app(); + let id = draft(&mut app, &[("select.all", json!({}))]); + app.run("actions.editor.record", json!({"draft":id})).unwrap(); + start_preview(&mut app, id, 0).unwrap(); + shutdown(&mut app); + assert!(app.session.actions.recording_active()); + assert!(app.session.actions.list.is_empty()); + assert!(app.session.actions.held_playback.is_none()); + shutdown(&mut app); + assert!(app.session.actions.recording_active()); +} + +#[test] +fn search_is_case_insensitive_and_new_group_keeps_classic_set_defaults() { + let mut app = app(); + app.session.actions.list = vec![Action::new("Warm contrast", Vec::new()), Action::new("Cool shadows", Vec::new())]; + app.ui.actions.search = "WARM".into(); + let mut h = harness(app); + h.get_by_label("Warm contrast"); + assert!(h.query_by_label("Cool shadows").is_none()); + h.get_by_label("Clear search").click(); + h.run_steps(3); + h.get_by_label("Cool shadows"); + let id = new_group(h.state_mut()).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["name"], "Group 1"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let id = new_set_dialog(h.state_mut()).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["name"], "Set 1"); + crate::dialogs::cancel(h.state_mut(), id).unwrap(); +} + +#[test] +fn closing_a_group_with_a_hidden_editor_defers_and_discard_replays_the_real_close() { + let mut app = app(); + app.run("window.panel.activate", json!({"panel":"actions"})).unwrap(); + app.run("window.panel.activate", json!({"panel":"history"})).unwrap(); + draft(&mut app, &[("select.all", json!({}))]); + app.run("window.panel.group", json!({"panel":"history","operation":"close"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().layout.contains(&"actions".into())); + assert!(app.actions_runtime.pending.is_some()); + let mut h = Harness::builder().with_size(vec2(480.0, 740.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + modals(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h.get_by_label("Discard").click(); + h.run_steps(3); + assert!(h.state().actions_runtime.page.is_none()); + assert!(!h.state().ui.docking.as_ref().unwrap().layout.contains(&"actions".into())); + assert!(!h.state().ui.docking.as_ref().unwrap().layout.contains(&"history".into())); +} + +#[test] +fn export_suggests_the_selected_action_only_with_a_portable_uppercase_extension() { + let mut app = app(); + app.session.actions.list = vec![Action::new("Other", Vec::new()), Action::new("Web/Print:Mail", Vec::new())]; + app.ui.actions.selected = Some(1); + let (file_dialog, asked) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::SaveTo("/virtual/selected.ATN".into()))]); + app.services.file_dialog = Some(file_dialog); + let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let output = written.clone(); + app.services.write = Some(Box::new(move |_, bytes| { + *output.borrow_mut() = bytes.to_vec(); + Ok(()) + })); + export(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(matches!(asked.borrow().as_slice(),[FileDialogRequest::Save{suggested}] if suggested=="Web_Print_Mail.ATN")); + let parsed = photocraft_psd::atn::parse(&written.borrow()).unwrap(); + assert_eq!(parsed.actions.len(), 1); + assert_eq!(parsed.actions[0].name.to_string_lossy(), "Web/Print:Mail"); +} + +#[test] +fn canonical_ungrouped_category_keeps_classic_loose_rows_flat_and_order_stable() { + let mut app = app(); + let mut explicit = Action::new("Explicit category", Vec::new()); + explicit.set = Some(photocraft_engine::atn_cmds::UNGROUPED_SET.into()); + let mut named = Action::new("Named action", Vec::new()); + named.set = Some("Named group".into()); + app.session.actions.list = vec![named, Action::new("Legacy none", Vec::new()), explicit]; + let original = app.session.actions.list.clone(); + let mut h = Harness::builder().with_size(vec2(480.0, 740.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::actions::panel(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(3); + h.get_by_label("Legacy none"); + h.get_by_label("Explicit category"); + assert!(h.query_by_label(photocraft_engine::atn_cmds::UNGROUPED_SET).is_none()); + h.state_mut().run("actions.layout.classic", json!({})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label(photocraft_engine::atn_cmds::UNGROUPED_SET).is_none(), "ordinary loose actions keep the original flat presentation"); + assert!(h.get_by_label("Legacy none").rect().top() < h.get_by_label("Explicit category").rect().top()); + assert!(h.get_by_label("Explicit category").rect().top() < h.get_by_label("Named action").rect().top(), "loose actions precede named folders"); + assert_eq!(h.state().session.actions.list, original, "presentation never rewrites legacy None or stored names"); + h.state_mut().ui.actions.selected = None; + h.state_mut().ui.actions.selected_set = Some(photocraft_engine::atn_cmds::UNGROUPED_SET.into()); + assert_eq!(super::super::playback_targets(h.state()), [1, 2]); + // A real retained empty category remains visible after its last action is removed. + h.state_mut().session.actions.list.clear(); + h.state_mut().run("actions.newSet", json!({"name":photocraft_engine::atn_cmds::UNGROUPED_SET})).unwrap(); + h.run_steps(3); + h.get_by_label(photocraft_engine::atn_cmds::UNGROUPED_SET); + h.state_mut().run("actions.layout.modern", json!({})).unwrap(); + h.run_steps(3); + h.get_by_label(photocraft_engine::atn_cmds::UNGROUPED_SET); +} diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index e35999dedc1..2d0f040c6c5 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1,4 +1,6 @@ use super::*; +// These unchanged assertions exercise the Classic presentation explicitly. +use super::{classic_panel as panel, classic_panel_menu as panel_menu}; /// A small valid Photoshop set, generated without external action files. fn action_set_bytes(set: &str, action: &str) -> Vec { diff --git a/crates/ui-egui/src/panels/panel_docking.rs b/crates/ui-egui/src/panels/panel_docking.rs index ebddecde2ce..df921de8d8b 100644 --- a/crates/ui-egui/src/panels/panel_docking.rs +++ b/crates/ui-egui/src/panels/panel_docking.rs @@ -471,6 +471,16 @@ fn run(app: &mut PhotocraftApp, command: DockCommand) -> Result Result { diff --git a/crates/ui-egui/src/prefs/prefs_ui/startup.rs b/crates/ui-egui/src/prefs/prefs_ui/startup.rs index 16b945b49e3..570c142428a 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/startup.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/startup.rs @@ -49,6 +49,7 @@ pub fn load(app: &mut PhotocraftApp) { sync_rulers(app); crate::tool_memory::restore(app); crate::view_cmds::restore(app); + crate::actions::restore(app); app.sync_recent(); app.prefs_rt.saved_rev = app.session.prefs.rev(); let loaded = app.session.prefs_value(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c18a418c4c4..7ff3ccd0479 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -17,6 +17,23 @@ pub use panel_state::{ pub use tool::Tool; pub use tool_options::{SampleAllLayers, ToolOptions}; use tool_options::{default_brush_tool, default_layer_filter_mode, yes}; + +/// The Actions panel's remembered presentation. Runtime owners live on the app. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ActionsLayout { + #[default] + Modern, + Classic, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ActionsListSize { + #[default] + Large, + Small, +} pub use transform::{ BoxResize, CamTransform, ContentAwareOptions, Extras, MadeLayer, TextEdit, TransformMode, TransformSession, TypeInsertionStyle, TypeTransform, }; @@ -367,6 +384,10 @@ pub struct UiState { /// Actions panel: which row is selected and which are expanded. The list lives on the session. #[serde(default)] pub actions: crate::actions::ActionsUi, + #[serde(default)] + pub actions_layout: ActionsLayout, + #[serde(default)] + pub actions_list_size: ActionsListSize, /// Layer Comps panel: the selected comp (by comp id). #[serde(default)] pub layer_comp_selected: Option, @@ -557,6 +578,8 @@ impl Default for UiState { extras: Extras::default(), view: Default::default(), actions: Default::default(), + actions_layout: Default::default(), + actions_list_size: Default::default(), layer_comp_selected: None, presets_ui: Default::default(), web_view: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2c08f63faae..4ce62d13c17 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -214,6 +214,24 @@ pub struct Tokens { } impl Tokens { + /// Semantic Actions thumbnail palette in ATN colour-index order. These are application + /// colours, independent of stored metadata; uncoloured/unknown values use the card tokens. + pub fn action_swatches(&self) -> [Color32; 12] { + [ + self.card, + Color32::from_rgb(196, 64, 64), + Color32::from_rgb(204, 120, 40), + Color32::from_rgb(196, 176, 48), + Color32::from_rgb(56, 140, 72), + Color32::from_rgb(64, 158, 216), + Color32::from_rgb(152, 120, 192), + self.text_dim, + Color32::from_rgb(40, 152, 152), + Color32::from_rgb(48, 104, 176), + Color32::from_rgb(192, 64, 144), + Color32::from_rgb(120, 72, 168), + ] + } /// Tool outlines need contrast on arbitrary document pixels, independently of UI theme. pub(crate) fn cursor_outline() -> [Color32; 2] { [Color32::from_black_alpha(160), Color32::from_white_alpha(235)] From 140480c5c1ff271aae258bb9c9b20dcc553c5449 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 17:18:55 -0800 Subject: [PATCH 081/384] Persist the complete Actions library as ATN --- apps/photocraft-cli/src/actions.rs | 52 +- apps/photocraft-cli/src/args.rs | 10 +- apps/photocraft-cli/src/batch.rs | 16 +- apps/photocraft-cli/tests/it/cli.rs | 69 ++- apps/photocraft-web/src/indexed_presets.rs | 54 +- apps/photocraft-web/src/preset_bridge.rs | 181 +++++- crates/engine/src/cmd/actions_cmds.rs | 53 +- crates/engine/src/cmd/actions_cmds/library.rs | 536 ++++++++++++++++++ .../src/cmd/actions_cmds/library_atn_tests.rs | 405 +++++++++++++ crates/engine/src/cmd/actions_cmds/tests.rs | 4 +- crates/engine/src/cmd/actions_edit_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds/convert.rs | 147 ++++- crates/engine/src/cmd/atn_cmds/files.rs | 27 +- crates/engine/src/cmd/atn_cmds/sidecar.rs | 132 ++++- .../engine/src/cmd/atn_cmds/sidecar_tests.rs | 3 +- crates/engine/src/cmd/atn_cmds/tests.rs | 156 +++-- crates/engine/src/preset_store.rs | 332 +++++++++-- crates/engine/src/preset_store/tests.rs | 40 ++ crates/engine/tests/it/action_metadata.rs | 95 ++-- crates/engine/tests/it/atn_action_colors.rs | 12 +- crates/format/src/lib.rs | 2 + crates/format/src/noreplace.rs | 160 ++++++ crates/psd/src/atn.rs | 197 ++++++- crates/psd/src/atn/sets.rs | 28 +- .../src/panels/actions/editor_tests.rs | 7 +- docs/atn.md | 31 +- docs/control-protocol.md | 2 +- docs/development.md | 9 +- 29 files changed, 2439 insertions(+), 325 deletions(-) create mode 100644 crates/engine/src/cmd/actions_cmds/library.rs create mode 100644 crates/engine/src/cmd/actions_cmds/library_atn_tests.rs create mode 100644 crates/format/src/noreplace.rs diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index f8b900e49ad..444769336c2 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + io::Read, + path::{Path, PathBuf}, +}; use photocraft_engine::actions_cmds::Action; use serde_json::{Value, json}; @@ -11,8 +14,10 @@ pub enum ActionsError { /// The steps don't parse ([`photocraft_engine::automate_cmds::parse_action`]). #[error(transparent)] Steps(#[from] photocraft_engine::EngineError), - #[error("--action picks an action from an Actions set ({{\"actions\": [{{\"name\", \"steps\"}}…]}}), but this file holds one action's steps")] + #[error("--action selects an action from an ATN library; a droplet already holds one action")] NotASet, + #[error("legacy JSON action libraries are no longer supported; use an ATN library or a droplet")] + LegacyLibrary, #[error("Actions set: {0}")] Set(#[source] serde_json::Error), #[error("no action named `{name}` in the set (it holds {})", quoted(names))] @@ -58,38 +63,43 @@ fn steps_of(v: &Value) -> Result { /// Engine commands to run in order: `(id, params)`. pub type Steps = Vec<(String, Value)>; -/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the -/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays -/// the action `name` picks (optional when the set holds one) with the whole set loaded, as -/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786). +/// Parse one droplet/step value. A legacy JSON action library is never imported here. pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec), ActionsError> { let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?; let list = v.get("actions").unwrap_or(&v); - let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string))); - if !is_set { - if name.is_some() { - return Err(ActionsError::NotASet); - } - return Ok((steps_of(&v)?, Vec::new())); + if list.as_array().is_some_and(|list| list.iter().any(|action| action.get("name").is_some())) { + return Err(ActionsError::LegacyLibrary); } - let set: Vec = serde_json::from_value(list.clone()).map_err(ActionsError::Set)?; - let action = match (name, set.as_slice()) { - (Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAction { name: n.to_owned(), names: names(&set) })?, - (None, [only]) => only, - (None, _) => return Err(ActionsError::PickOne { names: names(&set) }), - }; - let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; - Ok((play, set)) + if name.is_some() { + return Err(ActionsError::NotASet); + } + Ok((steps_of(&v)?, Vec::new())) } /// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by /// its version word, 12 or 16) is converted first; the played action's steps with no PhotoCraft /// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run). pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec, Vec), ActionsError> { - let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; + let file = std::fs::File::open(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; + let mut bytes = Vec::new(); + file.take(photocraft_engine::preset_store::MAX_ACTIONS_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; + if bytes.len() as u64 > photocraft_engine::preset_store::MAX_ACTIONS_BYTES { + return Err(ActionsError::Read { + path: path.to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, "Actions input exceeds byte limit"), + }); + } let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || matches!(bytes.get(..4), Some([0, 0, 0, 12 | 16])); if !is_atn { let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?; + let value: Value = serde_json::from_str(&text).map_err(ActionsError::Json)?; + if value.get("photocraftDroplet").and_then(Value::as_u64) != Some(1) + || !value.get("action").and_then(|action| action.get("steps")).is_some_and(Value::is_array) + { + return Err(ActionsError::LegacyLibrary); + } let (steps, set) = load_actions(&text, name)?; return Ok((steps, set, Vec::new())); } diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index c3583bf83d1..14750da19e7 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -166,15 +166,11 @@ pub(crate) enum Step { /// Apply an action list to every image in a directory /// -/// A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps -/// are [id, params] pairs, {"command": id, "params": {…}} objects or bare ids, as a recorded -/// action or droplet stores them (a list, or wrapped in {"actions": …}, {"steps": …} or a -/// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]}) -/// plays the action --action names (needed when the set holds more than one) with the whole set -/// loaded, so its steps can play other actions of the set. +/// Process images using an ATN Actions library or a PhotoCraft droplet. --action selects +/// one action from a library, with the whole library available for nested action calls. #[derive(Args)] pub(crate) struct BatchArgs { - /// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn) + /// An ATN Actions library (.atn) or a PhotoCraft droplet #[arg(long, value_name = "FILE")] pub(crate) actions: PathBuf, /// Which action of an Actions set to play diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index 347f829443c..de12940839d 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -214,8 +214,12 @@ mod batch_action_set_tests { {"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]}, {"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]}, ]}); - std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); - let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + let actions: Vec = serde_json::from_value(set["actions"].clone()).unwrap(); + let refs: Vec<_> = actions.iter().collect(); + let (atn, skipped) = photocraft_engine::atn_cmds::export("CLI tests", &refs, &[], false).unwrap(); + assert!(skipped.is_empty()); + std::fs::write(dir.join("actions.atn"), atn).unwrap(); + let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.atn"), "--action", action, "--in", &path("in"), "--out", &path(action)]); let Invoke { code, stdout, stderr } = batch("Outer"); assert_eq!(code, 0, "{stdout}{stderr}"); @@ -254,10 +258,14 @@ mod batch_action_set_tests { ["file.saveAs", {}], ], "disabled": [3], "dialogs": [1]}, ]}); - std::fs::write(dir.join("actions.json"), set.to_string()).unwrap(); + let actions: Vec = serde_json::from_value(set["actions"].clone()).unwrap(); + let refs: Vec<_> = actions.iter().collect(); + let (atn, skipped) = photocraft_engine::atn_cmds::export("CLI tests", &refs, &[], false).unwrap(); + assert!(skipped.is_empty()); + std::fs::write(dir.join("actions.atn"), atn).unwrap(); for action in ["Outer", "Rotate"] { let Invoke { code, stdout, stderr } = - invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]); + invoke(&["batch", "--actions", &path("actions.atn"), "--action", action, "--in", &path("in"), "--out", &path(action)]); assert_eq!(code, 0, "{stdout}{stderr}"); let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]); assert_eq!(code, 0, "{stderr}"); diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 386eafe62c0..be4d274361b 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -97,8 +97,8 @@ fn unknown_flags_are_usage_errors() { let input = d.join("in"); std::fs::create_dir_all(&input).unwrap(); write_png(&input.join("a.png"), 8, 4, 3); - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let actions = d.join("fixture.pcdroplet"); + write_droplet(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); let out_dir = d.join("out"); let fails = |cmd: &mut Command, flag: &str| { let o = cmd.output().unwrap(); @@ -433,9 +433,9 @@ fn batch_applies_actions_to_directory() { write_png(&inp.join("one.png"), 12, 8, 1); write_png(&inp.join("two.png"), 9, 9, 2); std::fs::write(inp.join("notes.txt"), "ignored").unwrap(); - let actions = d.join("actions.json"); + let actions = d.join("fixture.pcdroplet"); let log = d.join("errors.txt"); - std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap(); + write_droplet(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap(); let out = ok(bin() .args(["batch", "--actions"]) .arg(&actions) @@ -459,7 +459,7 @@ fn batch_applies_actions_to_directory() { assert_eq!(v["layers"][0]["name"], "Batch"); } // A failing action reports and exits 1. - std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap(); + write_droplet(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap(); let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap(); assert_eq!(o.status.code(), Some(1)); assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL")); @@ -475,8 +475,8 @@ fn batch_recursive_naming_stop_and_error_log() { write_png(&input.join(file), 8, 6, 3); } std::fs::write(input.join("sub/b.png"), b"not a png").unwrap(); - let actions = root.join("actions.json"); - std::fs::write(&actions, r#"[["image.imageRotation.90cw", {}]]"#).unwrap(); + let actions = root.join("fixture.pcdroplet"); + write_droplet(&actions, r#"[["image.imageRotation.90cw", {}]]"#).unwrap(); let output = root.join("out"); let log = root.join("errors.txt"); let run = |out: &Path, extra: &[&str]| { @@ -523,8 +523,8 @@ fn batch_naming_keeps_the_tree_order_and_the_selected_encoder() { std::fs::create_dir_all(input.join("sub")).unwrap(); write_png(&input.join("z.png"), 8, 6, 3); write_png(&input.join("sub/a.png"), 5, 4, 3); - let actions = root.join("actions.json"); - std::fs::write(&actions, "[]").unwrap(); + let actions = root.join("fixture.pcdroplet"); + write_droplet(&actions, "[]").unwrap(); let run = |out: &Path, extra: &[&str]| { bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(out).args(extra).output().unwrap() }; @@ -575,8 +575,8 @@ static char *logo[] = { ", ) .unwrap(); - let actions = root.join("actions.json"); - std::fs::write(&actions, "[]").unwrap(); + let actions = root.join("fixture.pcdroplet"); + write_droplet(&actions, "[]").unwrap(); for (name, extra) in [("plain", &[][..]), ("named", &["--naming", "{name}.{ext}"][..])] { let out = root.join(name); let result = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out).args(extra).output().unwrap(); @@ -641,10 +641,25 @@ fn parse_actions_formats() { } } -/// Runs `batch` on `input` with `actions` (saved as `.json`), writing to the folder `/`. +/// Keep file-input tests on the supported droplet route; the JSON step parser is tested separately. +fn write_droplet(path: &Path, text: impl AsRef<[u8]>) -> std::io::Result<()> { + let value: Value = serde_json::from_slice(text.as_ref()).map_err(std::io::Error::other)?; + let droplet = if value.get("photocraftDroplet").is_some() { + value + } else { + json!({"photocraftDroplet":1,"action":{"steps":value.get("steps").or_else(|| value.get("actions")).unwrap_or(&value)}}) + }; + std::fs::write(path, droplet.to_string()) +} + +/// Runs `batch` on `input` with `actions` (saved as a droplet), writing to the folder `/`. fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output { - let file = d.join(format!("{name}.json")); - std::fs::write(&file, actions).unwrap(); + let file = d.join(format!("{name}.pcdroplet")); + if serde_json::from_str::(actions).is_ok() { + write_droplet(&file, actions).unwrap(); + } else { + std::fs::write(&file, actions).unwrap(); + } bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap() } @@ -905,8 +920,8 @@ fn batch_refuses_to_write_over_its_inputs() { write_png(&input.join("a.png"), 8, 4, 3); let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap(); std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap(); - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let actions = d.join("fixture.pcdroplet"); + write_droplet(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); let snapshot = || { let mut files: Vec<(PathBuf, Vec)> = std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect(); @@ -949,8 +964,8 @@ fn batch_naming_rejects_unread_input_alias_through_a_missing_directory() { write_png(&input.join("a.png"), 8, 4, 3); write_png(&input.join("b.png"), 8, 4, 7); let original = std::fs::read(input.join("b.png")).unwrap(); - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let actions = d.join("fixture.pcdroplet"); + write_droplet(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); let output = bin() .arg("batch") .arg("--actions") @@ -1120,8 +1135,8 @@ fn batch_later_input_replaces_a_same_named_output() { for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] { std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap(); } - let actions = d.join("actions.json"); - std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); + let actions = d.join("fixture.pcdroplet"); + write_droplet(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap(); let out_dir = d.join("out"); let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap(); let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); @@ -1679,8 +1694,8 @@ fn batch_skips_deselect_with_nothing_selected() { std::fs::create_dir_all(dir.join("in")).unwrap(); let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]); assert_eq!(code, 0, "{stderr}"); - std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap(); - let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]); + write_droplet(&dir.join("steps.pcdroplet"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap(); + let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.pcdroplet"), "--in", &path("in"), "--out", &path("out")]); assert_eq!(code, 0, "{stdout}{stderr}"); assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}"); let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]); @@ -1721,3 +1736,13 @@ fn tiff_options_choose_compression_transparency_and_pyramid() { assert!(!out.exists(), "{flag} {bad} wrote a file"); } } + +#[test] +fn batch_rejects_a_legacy_json_library_before_creating_output() { + let d = tmp("legacy-action-library-rejected"); + let file = d.join("legacy-library.json"); + std::fs::write(&file, r#"{"version":1,"actions":[{"name":"Old","steps":[]}]}"#).unwrap(); + let result = photocraft_cli::load_actions_file(&file, Some("Old")); + assert!(result.unwrap_err().to_string().contains("legacy JSON action libraries")); + assert!(!d.join("out").exists()); +} diff --git a/apps/photocraft-web/src/indexed_presets.rs b/apps/photocraft-web/src/indexed_presets.rs index 23d814e6018..0c6161cc3a2 100644 --- a/apps/photocraft-web/src/indexed_presets.rs +++ b/apps/photocraft-web/src/indexed_presets.rs @@ -6,7 +6,7 @@ use wasm_bindgen_futures::JsFuture; use web_sys::{Blob, Event, EventTarget, IdbDatabase, IdbRequest, IdbTransactionMode}; use crate::preset_bridge::{Batch, Bridge, MAX_RECORDS, record_limit}; -use photocraft_engine::preset_store::MAX_STORE_BYTES; +use photocraft_engine::preset_store::{ACTIONS_FILE, LEGACY_ACTIONS_FILE, MAX_STORE_BYTES, MIGRATED_ACTIONS_FILE}; const DATABASE: &str = "photocraft.presets"; const FILES: &str = "files"; @@ -98,12 +98,27 @@ async fn before(promise: &Promise, deadline: f64) -> Result { async fn open(deadline: f64) -> Result { let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?; - let req = factory.open_with_u32(DATABASE, 1).map_err(js)?; + let req = factory.open_with_u32(DATABASE, 2).map_err(js)?; let mut upgrade = Listeners::new(); let upgrade_req = req.clone(); upgrade .add(&req, "upgradeneeded", move |_| { - let result = upgrade_req.result().and_then(|v| v.dyn_into::()).and_then(|db| db.create_object_store(FILES)); + if js_sys::Date::now() >= deadline { + if let Some(tx) = upgrade_req.transaction() { + let _ = tx.abort(); + } + return; + } + // A versionchange transaction excludes version1 tabs that could still write JSON. + // Keep the existing store when upgrading; no second persistence schema is added. + let result = upgrade_req.result().and_then(|v| v.dyn_into::()).and_then(|db| { + if let Some(tx) = upgrade_req.transaction() { + if let Ok(store) = tx.object_store(FILES) { + return Ok(store); + } + } + db.create_object_store(FILES) + }); if result.is_err() && let Some(tx) = upgrade_req.transaction() { @@ -113,7 +128,14 @@ async fn open(deadline: f64) -> Result { .map_err(js)?; let pending = request(&req); match before(&pending.promise, deadline).await { - Ok(value) => value.dyn_into().map_err(|_| "invalid preset database".into()), + Ok(value) => { + let db: IdbDatabase = value.dyn_into().map_err(|_| "invalid preset database")?; + let connection = db.clone(); + let close = Closure::::new(move |_| connection.close()); + db.set_onversionchange(Some(close.as_ref().unchecked_ref())); + close.forget(); // Connection lifetime is the page lifetime. + Ok(db) + } Err(e) => { // An open request cannot be cancelled (another tab may block its upgrade). Keep // its handlers alive and close any late connection; never hydrate a live session. @@ -210,14 +232,36 @@ async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec), } pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> { + if let Some(migration) = &batch.migration { + if !matches!(batch.get(LEGACY_ACTIONS_FILE), Some(None)) + || batch.get(MIGRATED_ACTIONS_FILE).and_then(Option::as_ref).is_none_or(|bytes| bytes.as_ref() != migration.legacy.as_ref()) + || batch.get(ACTIONS_FILE).and_then(Option::as_ref).is_none() + { + return Err("incomplete Actions migration transaction".into()); + } + } let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?; let store = tx.object_store(FILES).map_err(js)?; let queued = (|| -> Result<(), String> { for (name, bytes) in batch { let key = JsValue::from_str(name); if let Some(bytes) = bytes { + if let Some(migration) = &batch.migration { + if name == ACTIONS_FILE && migration.published && bytes.as_ref() == migration.atn.as_ref() { + continue; + } + if name == MIGRATED_ACTIONS_FILE && bytes.as_ref() != migration.legacy.as_ref() { + return Err("migration backup changed while staged".into()); + } + } let blob = Blob::new_with_u8_array_sequence(&Array::of1(&Uint8Array::from(bytes.as_ref()))).map_err(js)?; - store.put_with_key(&blob, &key).map_err(js)?; + let exclusive = + batch.migration.as_ref().is_some_and(|migration| name == MIGRATED_ACTIONS_FILE || (name == ACTIONS_FILE && !migration.published)); + if exclusive { + store.add_with_key(&blob, &key).map_err(js)?; + } else { + store.put_with_key(&blob, &key).map_err(js)?; + } } else { store.delete(&key).map_err(js)?; } diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index a39fdfb4b4b..58229404500 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -8,9 +8,32 @@ use photocraft_engine::{ preset_store::{self, PresetBackend, *}, }; -pub type Batch = BTreeMap>>; +pub type Files = BTreeMap>>; +#[derive(Clone)] +pub struct Migration { + pub legacy: Arc<[u8]>, + pub atn: Arc<[u8]>, + pub published: bool, +} +pub struct Batch { + pub files: Files, + pub migration: Option, +} +impl std::ops::Deref for Batch { + type Target = Files; + fn deref(&self) -> &Files { + &self.files + } +} +impl<'a> IntoIterator for &'a Batch { + type Item = (&'a String, &'a Option>); + type IntoIter = std::collections::btree_map::Iter<'a, String, Option>>; + fn into_iter(self) -> Self::IntoIter { + self.files.iter() + } +} /// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records. -pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3; +pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 5; #[derive(Default)] struct State { @@ -18,6 +41,7 @@ struct State { bytes: u64, dirty: BTreeSet, writing: bool, + migration: Option, error: Option, rejected: BTreeMap, preserve_tips: bool, @@ -44,7 +68,7 @@ pub fn record_limit(name: &str) -> Result { } match name { INDEX_FILE => Ok(MAX_INDEX_BYTES), - ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES), + ACTIONS_FILE | LEGACY_ACTIONS_FILE | MIGRATED_ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES), PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES), _ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES), _ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES), @@ -81,24 +105,27 @@ impl Bridge { return None; } s.writing = true; - Some( - std::mem::take(&mut s.dirty) - .into_iter() - .map(|name| { - let bytes = s.files.get(&name).cloned(); - (name, bytes) - }) - .collect(), - ) + let files = std::mem::take(&mut s.dirty) + .into_iter() + .map(|name| { + let bytes = s.files.get(&name).cloned(); + (name, bytes) + }) + .collect(); + Some(Batch { files, migration: s.migration.clone() }) } pub fn finish(&self, batch: Batch, result: Result<(), String>) { let mut s = self.state(); s.writing = false; - if let Err(e) = result { - // Requeue names, never old bytes: edits/deletes made during the failed write win. - s.dirty.extend(batch.into_keys()); - s.error = Some(e); + if let Err(error) = result { + // Requeue the whole migration and current bytes, including edits made in flight. + s.dirty.extend(batch.files.into_keys()); + s.error = Some(error); + } else if batch.migration.is_some() { + // Only transaction completion retires the pending migration. Any newer ATN edit + // remains in dirty and gets its own acknowledged transaction. + s.migration = None; } } @@ -135,7 +162,55 @@ impl PresetBackend for Bridge { Ok(bytes.to_vec()) } + fn migrate_actions(&self, legacy: &[u8], atn: &[u8], published: bool) -> Result { + let mut s = self.state(); + let result = (|| -> Result { + if legacy.len() as u64 > MAX_ACTIONS_BYTES || atn.len() as u64 > MAX_ACTIONS_BYTES { + return Err("Actions migration exceeds file limit".into()); + } + if s.migration.is_some() { + return Err("an Actions migration is already pending".into()); + } + if s.files.get(LEGACY_ACTIONS_FILE).map(AsRef::as_ref) != Some(legacy) { + return Err("legacy Actions input changed before migration".into()); + } + match s.files.get(ACTIONS_FILE) { + Some(bytes) if published && bytes.as_ref() == atn => {} + None if !published => {} + _ => return Err("Actions ATN changed before migration".into()), + } + if s.files.contains_key(MIGRATED_ACTIONS_FILE) { + return Err("legacy backup already exists; original and backup retained unchanged".into()); + } + let size = s.bytes.checked_add(if published { 0 } else { atn.len() as u64 }).ok_or("store size overflow")?; + let records = s.files.len() + usize::from(!published); + let pending = [ACTIONS_FILE, LEGACY_ACTIONS_FILE, MIGRATED_ACTIONS_FILE].iter().filter(|name| !s.dirty.contains(**name)).count(); + if size > MAX_STORE_BYTES || records > MAX_RECORDS || s.dirty.len() + pending > MAX_RECORDS { + return Err("browser preset store is full".into()); + } + let migration = Migration { legacy: Arc::from(legacy), atn: Arc::from(atn), published }; + // One lock, with all validation above mutation. add(), not put(), protects the backup + // and a first ATN publication in the actual IndexedDB transaction as well. + s.files.insert(ACTIONS_FILE.into(), migration.atn.clone()); + s.files.insert(MIGRATED_ACTIONS_FILE.into(), migration.legacy.clone()); + s.files.remove(LEGACY_ACTIONS_FILE); + s.bytes = size; + s.dirty.extend([ACTIONS_FILE.into(), LEGACY_ACTIONS_FILE.into(), MIGRATED_ACTIONS_FILE.into()]); + s.migration = Some(migration); + Ok(ActionsMigration::Staged) + })(); + if let Err(error) = &result { + s.rejected.insert(ACTIONS_FILE.into(), error.clone()); + } else { + s.rejected.remove(ACTIONS_FILE); + } + result + } + fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + if matches!(name, LEGACY_ACTIONS_FILE | MIGRATED_ACTIONS_FILE) { + return Err("legacy Actions records are migration-only".into()); + } let mut s = self.state(); if s.files.get(name).is_some_and(|old| old.as_ref() == bytes) { s.rejected.remove(name); @@ -158,6 +233,9 @@ impl PresetBackend for Bridge { } fn remove(&self, name: &str) -> Result<(), String> { + if matches!(name, LEGACY_ACTIONS_FILE | MIGRATED_ACTIONS_FILE) { + return Err("legacy Actions records are migration-only".into()); + } record_limit(name)?; let mut s = self.state(); if s.preserve_tips && name.starts_with("tips/") { @@ -184,6 +262,77 @@ mod tests { use photocraft_psd::abr::{AbrSample, LegacyBrush, LegacyTip, write_v12}; use serde_json::json; + #[test] + fn actions_migration_is_one_staged_batch_and_waits_for_completion() { + let bridge = Bridge::default(); + bridge.load(LEGACY_ACTIONS_FILE, b"exact legacy".to_vec()).unwrap(); + assert_eq!(bridge.migrate_actions(b"exact legacy", b"complete ATN", false).unwrap(), ActionsMigration::Staged); + assert!(bridge.unsaved()); + let batch = bridge.begin().unwrap(); + assert!(batch.migration.is_some()); + assert_eq!(batch.len(), 3); + assert!(matches!(batch.get(LEGACY_ACTIONS_FILE), Some(None))); + assert_eq!(batch.get(MIGRATED_ACTIONS_FILE).unwrap().as_deref(), Some(&b"exact legacy"[..])); + assert!(bridge.begin().is_none()); + bridge.finish(batch, Ok(())); + assert!(!bridge.unsaved()); + } + + #[test] + fn actions_migration_abort_requeues_whole_group_with_latest_atn() { + let bridge = Bridge::default(); + bridge.load(LEGACY_ACTIONS_FILE, b"legacy".to_vec()).unwrap(); + bridge.migrate_actions(b"legacy", b"first ATN", false).unwrap(); + let batch = bridge.begin().unwrap(); + bridge.write(ACTIONS_FILE, b"edited ATN").unwrap(); + bridge.finish(batch, Err("transaction aborted".into())); + assert!(bridge.unsaved()); + assert!(bridge.begin().is_none()); + bridge.retry(); + let batch = bridge.begin().unwrap(); + assert!(batch.migration.is_some()); + assert_eq!(batch.len(), 3); + assert_eq!(batch.get(ACTIONS_FILE).unwrap().as_deref(), Some(&b"edited ATN"[..])); + assert_eq!(batch.get(MIGRATED_ACTIONS_FILE).unwrap().as_deref(), Some(&b"legacy"[..])); + bridge.finish(batch, Ok(())); + assert!(!bridge.unsaved()); + } + + #[test] + fn actions_migration_validates_all_bindings_and_limits_before_mutation() { + let bridge = Bridge::default(); + bridge.load(LEGACY_ACTIONS_FILE, b"legacy".to_vec()).unwrap(); + let before = bridge.list().unwrap(); + assert!(bridge.migrate_actions(b"changed", b"ATN", false).is_err()); + assert_eq!(bridge.list().unwrap(), before); + bridge.state().bytes = MAX_STORE_BYTES; + assert!(bridge.migrate_actions(b"legacy", b"ATN", false).is_err()); + assert_eq!(bridge.list().unwrap(), before); + bridge.state().bytes = 6; + bridge.load(MIGRATED_ACTIONS_FILE, b"other backup".to_vec()).unwrap(); + assert!(bridge.migrate_actions(b"legacy", b"ATN", false).is_err()); + assert_eq!(bridge.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), b"other backup"); + assert_eq!(bridge.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), b"legacy"); + assert!(bridge.unsaved(), "a rejected migration remains visible until repaired or reloaded"); + } + + #[test] + fn acknowledged_migration_keeps_an_inflight_newer_edit_pending() { + let bridge = Bridge::default(); + bridge.load(LEGACY_ACTIONS_FILE, b"legacy".to_vec()).unwrap(); + bridge.migrate_actions(b"legacy", b"first ATN", false).unwrap(); + let batch = bridge.begin().unwrap(); + bridge.write(ACTIONS_FILE, b"newer ATN").unwrap(); + bridge.finish(batch, Ok(())); + assert!(bridge.unsaved()); + let next = bridge.begin().unwrap(); + assert!(next.migration.is_none()); + assert_eq!(next.len(), 1); + assert_eq!(next.get(ACTIONS_FILE).unwrap().as_deref(), Some(&b"newer ATN"[..])); + bridge.finish(next, Ok(())); + assert!(!bridge.unsaved()); + } + fn import(s: &mut Session) { let abr = write_v12( 2, diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 0541f286022..9fd4a7ca07b 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -23,6 +23,7 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; pub mod editor; +pub mod library; pub mod playback; /// A command temporarily owns the recorder while its nested commands run. @@ -37,7 +38,7 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { /// One recorded action. `steps` is the `[[id, params], …]` shape `file.automate.batch` and /// droplets already accept. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Action { pub name: String, /// Free-form editor description. Older native action libraries have no description. @@ -49,6 +50,12 @@ pub struct Action { /// Opaque imported sidecar data, moved with this action. The ATN library codec owns durability. #[serde(skip)] pub atn_metadata: Option, + /// Original physical record; runtime only, persisted by the single ATN library codec. + #[serde(skip)] + pub library_origin: Option, + /// Native parts of an original one-to-many ATN event, moved with their step indices. + #[serde(skip)] + pub library_steps: Vec>, #[serde(default)] pub steps: Vec<(String, Value)>, /// Indices of steps excluded from playback (unchecked in the Actions panel). @@ -68,6 +75,22 @@ pub struct Action { pub color: String, } +// Origins are codec bookkeeping, not a logical definition edit. Keep the existing comparison +// contract used by drafts and consumers; archive reloads do not change logical action equality. +impl PartialEq for Action { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.description == other.description + && self.icon == other.icon + && self.atn_metadata == other.atn_metadata + && self.steps == other.steps + && self.disabled == other.disabled + && self.dialogs == other.dialogs + && self.set == other.set + && self.color == other.color + } +} + /// Measured action colours in ATN index order. The classic picker offers the first eight. pub const BUTTON_COLORS: [&str; 12] = ["", "red", "orange", "yellow", "green", "blue", "violet", "gray", "teal", "blue2", "magenta", "purple"]; @@ -98,6 +121,8 @@ impl Action { description: String::new(), icon: String::new(), atn_metadata: None, + library_origin: None, + library_steps: Vec::new(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), @@ -108,6 +133,13 @@ impl Action { /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. pub(crate) fn remap_steps(&mut self, f: impl Fn(usize) -> Option) { + let mut origins = vec![None; self.steps.len()]; + for (index, origin) in self.library_steps.iter().enumerate() { + if let Some(to) = f(index).and_then(|to| origins.get_mut(to)) { + *to = origin.clone(); + } + } + self.library_steps = origins; self.disabled = self.disabled.iter().filter_map(|&i| f(i)).collect(); self.dialogs = self.dialogs.iter().filter_map(|&i| f(i)).collect(); } @@ -122,21 +154,17 @@ impl Action { } } -/// On-disk form of [`ActionState::list`] (`actions.json` in the preset store). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ActionsFile { - pub version: u32, - pub actions: Vec, -} - pub const MAX_PLAYBACK_STEPS: u64 = 100_000; -/// Actions list and the recording / playback flags. Only `list` is persisted. +/// Actions library and recording / playback flags. The ATN codec persists definitions and rows. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ActionState { pub list: Vec, - /// An unpublished editor value. Neither it nor its capture is part of actions.json. + /// Raw archive and physical rows, not a second name-keyed persisted library. + #[serde(skip)] + pub library: library::Library, + /// An unpublished editor value. Neither it nor its capture is part of the ATN library. #[serde(skip)] pub editor: Option, /// The shell owns a capture/preview across a step dialog. The opaque lease releases it. @@ -506,7 +534,7 @@ fn record(s: &mut Session, p: &Value) -> Result { } let set = match p.get("set") { None | Some(Value::Null) => None, - // An existing set, empty ones included, keeps its exact name (an older actions.json or + // An existing set, empty ones included, keeps its exact name (a legacy library or // a `.atn` may pad it); a new one is trimmed. Some(Value::String(n)) if crate::actions_edit_cmds::set_names(s).contains(n) => Some(n.clone()), Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), @@ -1134,6 +1162,9 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; +#[cfg(test)] +mod library_atn_tests; + #[cfg(test)] mod security_tests { use super::*; diff --git a/crates/engine/src/cmd/actions_cmds/library.rs b/crates/engine/src/cmd/actions_cmds/library.rs new file mode 100644 index 00000000000..b50eeaff327 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/library.rs @@ -0,0 +1,536 @@ +//! The sole durable Actions library. Runtime origins refer to physical ATN records, never names. +use std::{ops::Range, sync::Arc}; + +use photocraft_psd::{ + atn::{self, AtnAction, AtnFile, SpannedAtnArchive}, + descriptor::{Descriptor, UnicodeString, Value as D}, +}; + +use super::{Action, ActionState, color_to_atn}; +use crate::atn_cmds::{self, sidecar}; + +type Result = std::result::Result; +const MAX_BYTES: usize = crate::preset_store::MAX_ACTIONS_BYTES as usize; + +#[derive(Debug)] +struct Archive { + bytes: Arc<[u8]>, + parsed: SpannedAtnArchive, + hidden: Option, +} + +impl PartialEq for Archive { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self, other) + } +} + +/// An action's original record and the native projection used to detect actual step edits. +#[derive(Clone, Debug, PartialEq)] +pub struct ActionOrigin { + row: RowOrigin, + action: usize, + baseline: Arc, + cohorts: Vec>, +} + +/// One native part of one original event. Step remapping moves this with the step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StepOrigin { + item: usize, + part: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RowOrigin { + archive: Arc, + set: usize, +} + +impl RowOrigin { + pub(crate) fn key(&self) -> (usize, usize) { + (Arc::as_ptr(&self.archive) as usize, self.set) + } + fn file(&self) -> Result<&AtnFile> { + self.archive.parsed.sets.get(self.set).ok_or_else(|| "missing original set".into()) + } +} + +/// Receipt inside the ATN sidecar, sufficient to retire only the exact legacy input on restart. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LegacyOrigin { + bytes: usize, + digest: String, +} + +impl LegacyOrigin { + pub(crate) fn new(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err("legacy Actions library exceeds byte limit".into()); + } + Ok(Self { bytes: bytes.len(), digest: blake3::hash(bytes).to_hex().to_string() }) + } + pub(crate) fn matches(&self, bytes: &[u8]) -> bool { + bytes.len() == self.bytes && blake3::hash(bytes).to_hex().as_str() == self.digest + } + pub(crate) fn descriptor(&self) -> Result { + Ok(Descriptor::new("null") + .with("version", D::Integer(1)) + .with("bytes", D::Integer(i32::try_from(self.bytes).map_err(|_| "legacy length overflow")?)) + .with("digest", D::Text(UnicodeString::new(&self.digest)))) + } + pub(crate) fn from_descriptor(value: &Descriptor) -> Result { + if !value.class_id.is("null") || !value.name.0.is_empty() || value.items.len() != 3 { + return Err("invalid legacy Actions origin".into()); + } + let field = |key: &str| { + let mut values = value.items.iter().filter(|(id, _)| id.is(key)); + let value = values.next().map(|(_, value)| value).ok_or("missing legacy origin field")?; + if values.next().is_some() { + return Err("duplicate legacy origin field"); + } + Ok(value) + }; + if field("version")? != &D::Integer(1) { + return Err("unsupported legacy Actions origin".into()); + } + let D::Integer(length) = field("bytes")? else { + return Err("invalid legacy origin length".into()); + }; + let bytes = usize::try_from(*length).ok().filter(|length| *length <= MAX_BYTES).ok_or("invalid legacy origin length")?; + let D::Text(text) = field("digest")? else { + return Err("invalid legacy origin digest".into()); + }; + let digest = String::from_utf16(&text.0).map_err(|_| "invalid legacy origin digest")?; + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { + return Err("invalid legacy origin digest".into()); + } + Ok(Self { bytes, digest }) + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Library { + source: Option>, + pub(crate) rows: Vec>, + sidecar: Option>, + pub(crate) legacy: Option, +} + +pub(crate) struct RowIndex<'a> { + first: std::collections::HashMap<&'a str, RowOrigin>, + exact: std::collections::HashMap<(&'a str, (usize, usize)), RowOrigin>, +} +impl RowIndex<'_> { + pub(crate) fn source(&self, action: &Action) -> Option { + let name = atn_cmds::logical_set_name(action.set.as_deref()); + action.library_origin.as_ref().and_then(|origin| self.exact.get(&(name, origin.row.key()))).or_else(|| self.first.get(name)).cloned() + } +} + +impl Library { + pub(crate) fn row_index<'a>(&self, names: &'a [String]) -> RowIndex<'a> { + let mut index = RowIndex { first: Default::default(), exact: Default::default() }; + for (name, row) in names.iter().zip(&self.rows) { + if let Some(row) = row { + index.first.entry(name.as_str()).or_insert_with(|| row.clone()); + index.exact.entry((name.as_str(), row.key())).or_insert_with(|| row.clone()); + } + } + index + } + pub(crate) fn row_source(&self, names: &[String], action: &Action) -> Option { + self.row_index(names).source(action) + } +} + +/// A strict complete load; failure returns no partial library and executes no commands. +pub(crate) fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err("Actions library exceeds byte limit".into()); + } + let parsed = atn::parse_sets_with_spans(bytes).map_err(|error| error.to_string())?; + let raw_outer = parsed + .sets + .iter() + .enumerate() + .find_map(|(index, file)| sidecar::is_wrapper(file).then(|| parsed.spans.get(index)?.actions.first()?.items.first()?.descriptor.clone()).flatten()) + .and_then(|range| bytes.get(range)); + let mut metadata = sidecar::decode_library(&parsed.sets, raw_outer)?; + let legacy = sidecar::legacy_origin(metadata.source.as_deref())?; + let archive = Arc::new(Archive { bytes: bytes.into(), parsed, hidden: metadata.hidden }); + let mut state = ActionState::default(); + state.library.source = Some(archive.clone()); + state.library.sidecar = metadata.source; + state.library.legacy = legacy; + let mut total = 0usize; + for (set, file) in archive.parsed.sets.iter().enumerate().filter(|(index, _)| Some(*index) != archive.hidden) { + let wire_name = file.name.to_string_lossy(); + let row = RowOrigin { archive: archive.clone(), set }; + let slot = state.sets.len(); + state.sets.push(atn_cmds::logical_set_name((!wire_name.is_empty()).then_some(wire_name.as_str())).to_string()); + state.library.rows.push(Some(row.clone())); + if let Some(source) = metadata.sets.get_mut(set).and_then(Option::take) { + state.atn_set_rows.push(sidecar::SetRowMetadata { slot, row: state.sets.last().cloned().ok_or("missing loaded row")?, source }); + } + for (index, original) in file.actions.iter().enumerate() { + let (mut action, cohorts) = atn_cmds::library_action(file, original, &wire_name)?; + total = total.checked_add(action.steps.len()).filter(|count| *count <= atn::MAX_TOTAL_ITEMS).ok_or("too many native steps in Actions library")?; + if let Some(metadata) = metadata.actions.get_mut(set).and_then(|actions| actions.get_mut(index)).and_then(Option::take) { + sidecar::apply(&mut action, metadata); + } + let baseline = Arc::new(action.clone()); + action.library_steps = vec![None; action.steps.len()]; + for (item, cohort) in cohorts.iter().enumerate() { + for (part, position) in cohort.clone().enumerate() { + *action.library_steps.get_mut(position).ok_or("invalid native event projection")? = Some(StepOrigin { item, part }); + } + } + action.library_origin = Some(ActionOrigin { row: row.clone(), action: index, baseline, cohorts }); + state.list.push(action); + } + } + Ok(state) +} + +/// External import may normalize names, while retaining the original event cohorts and bytes. +pub(crate) fn bind_import(action: &mut Action, original: &Action) { + let Some(mut origin) = original.library_origin.clone() else { return }; + let mut baseline = original.library_origin.as_ref().map(|origin| (*origin.baseline).clone()).unwrap_or_default(); + baseline.steps = action.steps.clone(); + origin.baseline = Arc::new(baseline); + action.library_origin = Some(origin); + action.library_steps = original.library_steps.clone(); + action.atn_metadata = original.atn_metadata.clone(); +} + +pub(crate) fn import_row(state: &mut ActionState, imported: &ActionState, index: usize, slot: usize) { + state.library.rows.resize(state.sets.len(), None); + if let Some(row) = state.library.rows.get_mut(slot) { + *row = imported.library.rows.get(index).cloned().flatten(); + } + if state.library.sidecar.is_none() { + if imported.library.sidecar.is_some() || state.library.source.is_none() { + state.library.source = imported.library.source.clone(); + } + state.library.sidecar = imported.library.sidecar.clone(); + } +} + +/// Attach complete physical state. Edits made while asynchronous startup ran survive, as do +/// every disk occurrence and empty row; name collisions do not discard either definition. +pub(crate) fn attach(live: &mut ActionState, disk: ActionState) { + let offset = live.sets.len(); + live.library.rows.resize(offset, None); + live.library.rows.extend(disk.library.rows); + if disk.library.sidecar.is_some() || live.library.sidecar.is_none() { + live.library.source = disk.library.source; + live.library.sidecar = disk.library.sidecar; + } + live.library.legacy = disk.library.legacy; + live.list.extend(disk.list); + live.sets.extend(disk.sets); + live.atn_set_rows.extend(disk.atn_set_rows.into_iter().map(|mut row| { + row.slot += offset; + row + })); + live.rev = live.rev.saturating_add(1).max(1); +} + +/// Unique names can be attached to the existing shortcut preferences; ambiguous headers stay raw. +pub(crate) fn function_keys(state: &ActionState) -> Vec<(String, String)> { + let mut counts = std::collections::HashMap::new(); + for action in &state.list { + *counts.entry(action.name.as_str()).or_insert(0usize) += 1; + } + state + .list + .iter() + .filter(|action| counts.get(action.name.as_str()) == Some(&1)) + .filter_map(|action| { + let origin = action.library_origin.as_ref()?; + let header = origin.row.file().ok()?.actions.get(origin.action)?; + (header.function_key != 0).then(|| (action.name.clone(), atn_cmds::function_key_name(header.function_key, header.shift, header.command))) + }) + .collect() +} + +struct ItemOrigin { + row: RowOrigin, + action: usize, + item: usize, + flags_changed: bool, +} + +struct OutputAction { + origin: Option, + items: Vec>, +} + +fn original_action(origin: &ActionOrigin) -> Result<&AtnAction> { + origin.row.file()?.actions.get(origin.action).ok_or_else(|| "missing original action".into()) +} + +fn action_record(action: &Action, set: &str, keys: &[(String, String)], unique_name: bool) -> Result<(AtnAction, OutputAction)> { + let origin = action.library_origin.as_ref(); + let original = origin.map(original_action).transpose()?; + let mut output = original.cloned().unwrap_or_default(); + if origin.is_none_or(|origin| action.name != origin.baseline.name) { + output.name = UnicodeString::new_nul(&action.name); + } + output.color = color_to_atn(&action.color).ok_or("invalid Actions library color")?; + if unique_name { + let key = keys.iter().find(|(name, _)| name == &action.name).map(|(_, key)| key.as_str()).filter(|key| !key.is_empty()); + let previous = original.filter(|old| old.function_key > 0).map(|old| atn_cmds::function_key_name(old.function_key, old.shift, old.command)); + if key != previous.as_deref() { + let (number, shift, command) = key.and_then(atn_cmds::parse_function_key).unwrap_or((0, false, false)); + output.function_key = number; + output.shift = shift; + output.command = command; + } + } + output.items.clear(); + let mut origins = Vec::new(); + let mut position = 0usize; + while position < action.steps.len() { + let cohort = origin.and_then(|origin| { + let step = action.library_steps.get(position)?.as_ref()?; + if step.part != 0 { + return None; + } + let range = origin.cohorts.get(step.item)?; + if range.is_empty() { + return None; + } + let end = position.checked_add(range.len())?; + if end > action.steps.len() { + return None; + } + let unchanged = range.clone().enumerate().all(|(part, old)| { + let new = position + part; + action.library_steps.get(new).and_then(Option::as_ref) == Some(&StepOrigin { item: step.item, part }) + && action.steps.get(new) == origin.baseline.steps.get(old) + }); + let first_flags = (action.disabled.contains(&position), action.dialogs.contains(&position)); + let uniform = (position..end).all(|new| (action.disabled.contains(&new), action.dialogs.contains(&new)) == first_flags); + (unchanged && uniform).then_some((step.item, range.clone(), end)) + }); + if let (Some(origin), Some((item, range, end))) = (origin, cohort) { + let mut record = original.ok_or("missing original action")?.items.get(item).cloned().ok_or("missing original event")?; + let flags_changed = action.disabled.contains(&position) != origin.baseline.disabled.contains(&range.start) + || action.dialogs.contains(&position) != origin.baseline.dialogs.contains(&range.start); + if flags_changed { + record.enabled = !action.disabled.contains(&position); + record.with_dialog = action.dialogs.contains(&position); + } + output.items.push(record); + origins.push(Some(ItemOrigin { row: origin.row.clone(), action: origin.action, item, flags_changed })); + position = end; + } else { + let (id, params) = action.steps.get(position).ok_or("missing action step")?; + for mut record in atn_cmds::library_items(id, params, set)? { + record.enabled = !action.disabled.contains(&position); + record.with_dialog = action.dialogs.contains(&position); + output.items.push(record); + origins.push(None); + } + position += 1; + } + if output.items.len() > atn::MAX_ITEMS { + return Err("too many events in action".into()); + } + } + Ok((output, OutputAction { origin: origin.cloned(), items: origins })) +} + +fn append(out: &mut Vec, bytes: &[u8], range: Range) -> Result<()> { + let value = bytes.get(range).ok_or("invalid Actions library byte span")?; + if out.len().checked_add(value.len()).is_none_or(|length| length > MAX_BYTES) { + return Err("Actions library exceeds byte limit".into()); + } + out.extend_from_slice(value); + Ok(()) +} + +fn field(out: &mut Vec, canonical: &[u8], new: Range, source: Option<(&Archive, Range)>, unchanged: bool) -> Result<()> { + if let Some((source, old)) = source.filter(|_| unchanged) { append(out, &source.bytes, old) } else { append(out, canonical, new) } +} + +/// Canonical encoding supplies boundaries; unchanged original fields/events are then spliced back. +/// No second ATN grammar or persisted shadow registry is involved. +pub(crate) fn encode(state: &ActionState, keys: &[(String, String)]) -> Result> { + let mut text_bytes = 0usize; + for text in state.sets.iter().map(String::as_str).chain(state.list.iter().flat_map(|action| [&*action.name, &*action.description, &*action.icon])) { + text_bytes = text_bytes + .checked_add(text.encode_utf16().count().checked_mul(2).ok_or("Actions text length overflow")?) + .filter(|bytes| *bytes <= MAX_BYTES) + .ok_or("Actions text exceeds library byte limit")?; + } + let groups = sidecar::groups(state, None)?; + let mut names = std::collections::HashMap::new(); + for action in &state.list { + *names.entry(action.name.as_str()).or_insert(0usize) += 1; + } + let mut files = Vec::new(); + let mut records = Vec::new(); + for (name, selection) in &groups { + let original = selection.raw.as_ref().map(RowOrigin::file).transpose()?; + let mut file = original.cloned().unwrap_or_default(); + let wire = atn_cmds::wire_set_name(name); + if original.is_none_or(|old| { + let source_name = old.name.to_string_lossy(); + atn_cmds::logical_set_name((!source_name.is_empty()).then_some(source_name.as_str())) != *name + }) { + file.name = UnicodeString::new_nul(wire); + } + file.actions.clear(); + let mut actions = Vec::new(); + for action in &selection.actions { + let (record, origin) = action_record(action, name, keys, names.get(action.name.as_str()) == Some(&1))?; + file.actions.push(record); + actions.push(origin); + } + files.push(file); + records.push(actions); + } + let selected: Vec<_> = groups + .iter() + .map(|(_, selection)| sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone(), raw: selection.raw.clone() }) + .collect(); + let outer = sidecar::append_library(&mut files, &selected, state.library.sidecar.clone(), state.library.legacy.as_ref())?; + let canonical = atn::write_sets(&files).map_err(|error| error.to_string())?; + if canonical.len() > MAX_BYTES { + return Err("Actions library exceeds byte limit".into()); + } + let encoded = atn::parse_sets_with_spans(&canonical).map_err(|error| error.to_string())?; + let mut out = Vec::with_capacity(canonical.len()); + append(&mut out, &canonical, 0..4)?; + for (index, file) in files.iter().enumerate() { + let span = encoded.spans.get(index).ok_or("missing output set span")?; + if index >= groups.len() { + if let (Some(outer), Some(source)) = (&outer, &state.library.source) { + if let Some(old) = source.hidden.and_then(|hidden| source.parsed.spans.get(hidden)) { + let descriptor = old + .actions + .first() + .and_then(|action| action.items.first()) + .and_then(|item| item.descriptor.clone()) + .ok_or("missing original sidecar span")?; + append(&mut out, &source.bytes, old.range.start..descriptor.start)?; + append(&mut out, outer, 0..outer.len())?; + append(&mut out, &source.bytes, descriptor.end..old.range.end)?; + continue; + } + } + // Newly created wrapper uses the canonical header and the exact patched descriptor. + if let Some(outer) = &outer { + let descriptor = span + .actions + .first() + .and_then(|action| action.items.first()) + .and_then(|item| item.descriptor.clone()) + .ok_or("missing output sidecar span")?; + append(&mut out, &canonical, span.range.start..descriptor.start)?; + append(&mut out, outer, 0..outer.len())?; + append(&mut out, &canonical, descriptor.end..span.range.end)?; + } else { + append(&mut out, &canonical, span.range.clone())?; + } + continue; + } + let row = groups.get(index).and_then(|(_, selection)| selection.raw.as_ref()); + let old = row.map(RowOrigin::file).transpose()?; + let raw = row.and_then(|row| row.archive.parsed.spans.get(row.set)); + field( + &mut out, + &canonical, + span.name.clone(), + row.zip(raw).map(|(row, raw)| (row.archive.as_ref(), raw.name.clone())), + old.is_some_and(|old| old.name == file.name), + )?; + field( + &mut out, + &canonical, + span.expanded.clone(), + row.zip(raw).map(|(row, raw)| (row.archive.as_ref(), raw.expanded.clone())), + old.is_some_and(|old| old.expanded == file.expanded), + )?; + field( + &mut out, + &canonical, + span.count.clone(), + row.zip(raw).map(|(row, raw)| (row.archive.as_ref(), raw.count.clone())), + old.is_some_and(|old| old.actions.len() == file.actions.len()), + )?; + for (at, action) in file.actions.iter().enumerate() { + let span = span.actions.get(at).ok_or("missing output action span")?; + let record = records.get(index).and_then(|actions| actions.get(at)).ok_or("missing output action origin")?; + let origin = record.origin.as_ref(); + let old = origin.map(original_action).transpose()?; + let raw = origin.and_then(|origin| origin.row.archive.parsed.spans.get(origin.row.set)?.actions.get(origin.action)); + macro_rules! header { + ($field:ident) => { + field( + &mut out, + &canonical, + span.$field.clone(), + origin.zip(raw).map(|(origin, raw)| (origin.row.archive.as_ref(), raw.$field.clone())), + old.is_some_and(|old| old.$field == action.$field), + )?; + }; + } + header!(function_key); + header!(shift); + header!(command); + header!(color); + header!(name); + header!(expanded); + field( + &mut out, + &canonical, + span.count.clone(), + origin.zip(raw).map(|(origin, raw)| (origin.row.archive.as_ref(), raw.count.clone())), + old.is_some_and(|old| old.items.len() == action.items.len()), + )?; + for (item, span) in span.items.iter().enumerate() { + if let Some(origin) = record.items.get(item).and_then(Option::as_ref) { + let raw = origin + .row + .archive + .parsed + .spans + .get(origin.row.set) + .and_then(|row| row.actions.get(origin.action)) + .and_then(|action| action.items.get(origin.item)) + .ok_or("missing original item span")?; + if origin.flags_changed { + let original = + origin.row.file()?.actions.get(origin.action).and_then(|action| action.items.get(origin.item)).ok_or("missing original item")?; + let current = action.items.get(item).ok_or("missing output item")?; + let same = [ + original.expanded == current.expanded, + original.enabled == current.enabled, + original.with_dialog == current.with_dialog, + original.dialog_options == current.dialog_options, + ]; + for (offset, same) in same.into_iter().enumerate() { + field( + &mut out, + &canonical, + span.flags.start + offset..span.flags.start + offset + 1, + Some((origin.row.archive.as_ref(), raw.flags.start + offset..raw.flags.start + offset + 1)), + same, + )?; + } + append(&mut out, &origin.row.archive.bytes, raw.flags.end..raw.range.end)?; + } else { + append(&mut out, &origin.row.archive.bytes, raw.range.clone())?; + } + } else { + append(&mut out, &canonical, span.range.clone())?; + } + } + } + } + Ok(out) +} diff --git a/crates/engine/src/cmd/actions_cmds/library_atn_tests.rs b/crates/engine/src/cmd/actions_cmds/library_atn_tests.rs new file mode 100644 index 00000000000..74860362052 --- /dev/null +++ b/crates/engine/src/cmd/actions_cmds/library_atn_tests.rs @@ -0,0 +1,405 @@ +//! Codec and migration tests never execute the commands carried by untrusted action files. +use super::{Action, ActionState, library}; +use crate::{ + atn_cmds, + preset_store::{self, ACTIONS_FILE, LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES, MIGRATED_ACTIONS_FILE, MemBackend, PresetBackend}, +}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::json; + +fn wire() -> Vec { + fn ascii(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); + } + let mut out = 16u32.to_be_bytes().to_vec(); + // Independent wire oracle: duplicate names, unpaired UTF16 and noncanonical true bytes. + let name = [0, 0, 0, 2, 0xd8, 0, 0, 0]; + for _ in 0..2 { + out.extend_from_slice(&name); + out.push(2); + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&[0, 3, 2, 0, 0, 12]); + out.extend_from_slice(&name); + out.push(2); + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&[2, 2, 0, 3]); + out.extend_from_slice(b"TEXT"); + ascii(&mut out, b"futureEvent"); + ascii(&mut out, b"Opaque"); + out.extend_from_slice(&(-1i32).to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(b"null"); + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(b"flagbool"); + out.push(2); + } + out +} + +fn encode(state: &ActionState) -> Vec { + library::encode(state, &library::function_keys(state)).unwrap() +} + +#[test] +fn untouched_duplicate_physical_records_and_noncanonical_bytes_survive() { + let bytes = wire(); + let state = library::decode(&bytes).unwrap(); + assert_eq!(state.sets.len(), 2); + assert_eq!(state.list.len(), 2); + assert_eq!(state.list[0].name, state.list[1].name); + assert_eq!(state.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(encode(&state), bytes); + assert_ne!(atn::write_sets(&atn::parse_sets(&bytes).unwrap()).unwrap(), bytes); +} + +#[test] +fn changing_one_name_preserves_every_other_original_field_and_occurrence() { + let bytes = wire(); + let spans = atn::parse_sets_with_spans(&bytes).unwrap(); + let mut state = library::decode(&bytes).unwrap(); + state.list[0].name = "Edited".into(); + let output = encode(&state); + let new = atn::parse_sets_with_spans(&output).unwrap(); + assert_eq!(&output[new.spans[1].range.clone()], &bytes[spans.spans[1].range.clone()]); + let old = &spans.spans[0].actions[0]; + let changed = &new.spans[0].actions[0]; + assert_eq!(&output[changed.items[0].range.clone()], &bytes[old.items[0].range.clone()]); + assert_eq!(&output[changed.shift.clone()], &[2]); + assert_eq!(&output[changed.color.clone()], &[0, 12]); + assert_eq!(new.sets[0].actions[0].name.to_string_lossy(), "Edited"); +} + +fn close_archive() -> Vec { + atn::write_sets(&[AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Save and close"), + items: vec![AtnItem { + event: AtnEvent::name("close"), + descriptor: Some(Descriptor::new("null").with("Svng", D::Enumerated { type_id: Id::new("YsN "), value: Id::new("Ys ") })), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }]) + .unwrap() +} + +#[test] +fn one_event_expanding_to_two_native_steps_is_written_once_until_edited() { + let bytes = close_archive(); + let mut state = library::decode(&bytes).unwrap(); + assert_eq!(state.list[0].steps, [("file.save".into(), json!({})), ("file.close".into(), json!({}))]); + assert_eq!(encode(&state), bytes); + state.list[0].steps.insert(0, ("future.command".into(), json!({"x": 3}))); + state.list[0].remap_steps(|index| Some(index + 1)); + let output = encode(&state); + let parsed = atn::parse_sets(&output).unwrap(); + assert_eq!(parsed[0].actions[0].items.len(), 2, "insert plus the original single Close event"); + assert_eq!(library::decode(&output).unwrap().list[0].steps, state.list[0].steps); + state.list[0].steps[1].1 = json!({"futureOption": true}); + let output = encode(&state); + assert_eq!(library::decode(&output).unwrap().list[0].steps, state.list[0].steps, "editing a part must not replay the original whole cohort too"); +} + +#[test] +fn native_unknown_commands_keep_exact_params_and_flags_without_dispatch() { + let mut action = Action::new("Native", vec![("unregistered.future.command".into(), json!({"float": 2.0, "nested": [true, null]}))]); + action.disabled.insert(0); + action.dialogs.insert(0); + action.description = "Description".into(); + action.icon = "FutureIcon".into(); + let state = ActionState { list: vec![action.clone()], ..Default::default() }; + let bytes = encode(&state); + let loaded = library::decode(&bytes).unwrap(); + assert_eq!(loaded.list[0].steps, action.steps); + assert_eq!(loaded.list[0].disabled, action.disabled); + assert_eq!(loaded.list[0].dialogs, action.dialogs); + assert_eq!(loaded.list[0].description, action.description); + assert_eq!(loaded.list[0].icon, action.icon); + assert_eq!(encode(&loaded), bytes); +} + +#[test] +fn invalid_known_native_payload_rejects_the_complete_archive() { + let mut file = atn::parse_sets(&close_archive()).unwrap().remove(0); + file.actions[0].items.push(AtnItem { + event: AtnEvent::name("photocraftCommand"), + descriptor: Some( + Descriptor::new("null").with("command", D::Text(UnicodeString::new("future.command"))).with("params", D::Text(UnicodeString::new("{broken"))), + ), + ..Default::default() + }); + assert!(library::decode(&atn::write_sets(&[file]).unwrap()).is_err()); + let mut partial = wire(); + partial.pop(); + assert!(library::decode(&partial).is_err()); +} + +#[test] +fn empty_library_has_a_known_carrier_and_no_invented_user_folder() { + let bytes = encode(&ActionState::default()); + let loaded = library::decode(&bytes).unwrap(); + assert!(loaded.list.is_empty()); + assert!(loaded.sets.is_empty()); + assert_eq!(atn::parse_sets(&bytes).unwrap().len(), 1); + assert_eq!(encode(&loaded), bytes); + assert!(library::decode(&16u32.to_be_bytes()).is_err()); +} + +fn legacy() -> Vec { + br#" {"version":1,"actions":[{"name":"Legacy","description":"kept","steps":[["future.command",{"x":1}]],"disabled":[0]}]} "#.to_vec() +} + +#[test] +fn migration_publishes_one_atn_and_renames_exact_json_bytes_once() { + for bytes in [legacy(), br#"{"version":1,"actions":[]}"#.to_vec()] { + let mem = MemBackend::default(); + mem.files.lock().unwrap().insert(LEGACY_ACTIONS_FILE.into(), bytes.clone()); + let opened = preset_store::open(Box::new(mem.clone())); + assert!(opened.warnings.is_empty(), "{:?}", opened.warnings); + assert!(matches!(opened.actions, preset_store::LoadedActions::Loaded(_) | preset_store::LoadedActions::Migrated(_))); + assert!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); + assert_eq!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + let atn = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let state = library::decode(&atn).unwrap(); + assert!(state.library.legacy.as_ref().unwrap().matches(&bytes)); + let reopened = preset_store::open(Box::new(mem.clone())); + assert!(reopened.warnings.is_empty(), "{:?}", reopened.warnings); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), atn); + } +} + +#[test] +fn corrupt_atn_has_precedence_and_corrupt_legacy_is_never_overwritten() { + for (name, bytes) in [(ACTIONS_FILE, b"broken ATN".to_vec()), (LEGACY_ACTIONS_FILE, b"broken JSON".to_vec())] { + let mem = MemBackend::default(); + mem.files.lock().unwrap().insert(name.into(), bytes.clone()); + if name == ACTIONS_FILE { + mem.files.lock().unwrap().insert(LEGACY_ACTIONS_FILE.into(), legacy()); + } + let opened = preset_store::open(Box::new(mem.clone())); + assert!(matches!(opened.actions, preset_store::LoadedActions::Failed)); + assert!(!opened.warnings.is_empty()); + let mut session = crate::Session::new(); + session.attach_preset_store(opened); + session.actions.list.push(Action::new("Local", vec![])); + session.actions.rev += 1; + session.sync_preset_store(); + assert_eq!(mem.read(name, MAX_ACTIONS_BYTES).unwrap(), bytes); + assert!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); + } +} + +#[test] +fn retirement_conflict_keeps_all_copies_and_restart_does_not_guess_a_backup_name() { + let mem = MemBackend::default(); + let bytes = legacy(); + mem.files.lock().unwrap().insert(LEGACY_ACTIONS_FILE.into(), bytes.clone()); + mem.files.lock().unwrap().insert(MIGRATED_ACTIONS_FILE.into(), b"older backup".to_vec()); + let opened = preset_store::open(Box::new(mem.clone())); + assert!(matches!(opened.actions, preset_store::LoadedActions::Loaded(_) | preset_store::LoadedActions::Migrated(_))); + assert!(opened.warnings.iter().any(|warning| warning.contains("pending"))); + let atn = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + preset_store::open(Box::new(mem.clone())); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), atn); + assert_eq!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + assert_eq!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), b"older backup"); +} + +#[test] +fn preattachment_edits_empty_rows_and_same_name_disk_occurrences_all_survive() { + let mut live = ActionState { list: vec![Action::new("Same", vec![])], sets: vec!["Live empty".into()], rev: 1, ..Default::default() }; + let disk = library::decode(&wire()).unwrap(); + library::attach(&mut live, disk); + assert_eq!(live.list.len(), 3); + assert_eq!(live.sets[0], "Live empty"); + let output = encode(&live); + let loaded = library::decode(&output).unwrap(); + assert_eq!(loaded.list.len(), 3); + assert!(loaded.sets.iter().any(|name| name == "Live empty")); +} + +#[test] +fn changing_one_step_flag_preserves_other_raw_flag_bytes_and_descriptor() { + let bytes = wire(); + let old = atn::parse_sets_with_spans(&bytes).unwrap(); + let mut state = library::decode(&bytes).unwrap(); + state.list[0].disabled.insert(0); + let output = encode(&state); + let parsed = atn::parse_sets_with_spans(&output).unwrap(); + let original = &old.spans[0].actions[0].items[0]; + let changed = &parsed.spans[0].actions[0].items[0]; + assert_eq!(&output[changed.flags.clone()], &[2, 0, 0, 3]); + assert_eq!(&output[changed.flags.end..changed.range.end], &bytes[original.flags.end..original.range.end]); +} + +#[test] +fn deleting_one_native_part_does_not_resurrect_the_original_multi_step_event() { + let mut state = library::decode(&close_archive()).unwrap(); + state.list[0].steps.remove(0); + state.list[0].remap_steps(|index| index.checked_sub(1)); + let output = encode(&state); + assert_eq!(library::decode(&output).unwrap().list[0].steps, [("file.close".into(), json!({}))]); +} + +#[test] +fn shortcut_only_edits_change_headers_without_touching_original_event_bytes() { + let bytes = close_archive(); + let state = library::decode(&bytes).unwrap(); + let output = library::encode(&state, &[("Save and close".into(), "Cmd+Shift+F8".into())]).unwrap(); + let old = atn::parse_sets_with_spans(&bytes).unwrap(); + let new = atn::parse_sets_with_spans(&output).unwrap(); + let header = &new.sets[0].actions[0]; + assert_eq!((header.function_key, header.shift, header.command), (8, true, true)); + assert_eq!(&output[new.spans[0].actions[0].items[0].range.clone()], &bytes[old.spans[0].actions[0].items[0].range.clone()]); +} + +#[test] +fn portable_export_keeps_or_reports_native_steps_instead_of_truncating_options() { + let action = Action::new("Native", vec![("future.command".into(), json!({"option": true}))]); + let (kept, skipped) = atn_cmds::export("S", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn_cmds::import(&kept).unwrap().actions[0].steps, action.steps); + let (portable, skipped) = atn_cmds::export("S", &[&action], &[], true).unwrap(); + assert_eq!(skipped.len(), 1); + assert!(atn::parse_sets(&portable).unwrap()[0].actions[0].items.is_empty()); +} + +fn interrupted_migration(mem: &MemBackend, bytes: &[u8]) -> Vec { + let mut state = ActionState::default(); + state.library.legacy = Some(library::LegacyOrigin::new(bytes).unwrap()); + let atn = encode(&state); + let mut files = mem.files.lock().unwrap(); + files.insert(ACTIONS_FILE.into(), atn.clone()); + files.insert(LEGACY_ACTIONS_FILE.into(), bytes.to_vec()); + atn +} + +#[test] +fn restart_after_atn_commit_completes_exact_legacy_retirement_without_rewriting_atn() { + let mem = MemBackend::default(); + let bytes = legacy(); + let atn = interrupted_migration(&mem, &bytes); + let opened = preset_store::open(Box::new(mem.clone())); + assert!(opened.warnings.is_empty(), "{:?}", opened.warnings); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), atn); + assert_eq!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + assert!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); +} + +#[test] +fn restart_preserves_changed_sources_and_reports_missing_recovery_copies() { + let mem = MemBackend::default(); + let atn = interrupted_migration(&mem, &legacy()); + mem.files.lock().unwrap().insert(LEGACY_ACTIONS_FILE.into(), b"changed source".to_vec()); + let opened = preset_store::open(Box::new(mem.clone())); + assert!(opened.warnings.iter().any(|warning| warning.contains("differing legacy"))); + assert_eq!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), b"changed source"); + assert!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); + mem.files.lock().unwrap().remove(LEGACY_ACTIONS_FILE); + let opened = preset_store::open(Box::new(mem.clone())); + assert!(opened.warnings.iter().any(|warning| warning.contains("recovery copy"))); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), atn); +} + +#[test] +fn unrelated_json_is_never_retired_merely_because_valid_atn_exists() { + let mem = MemBackend::default(); + let atn = close_archive(); + let mut files = mem.files.lock().unwrap(); + files.insert(ACTIONS_FILE.into(), atn.clone()); + files.insert(LEGACY_ACTIONS_FILE.into(), legacy()); + drop(files); + let opened = preset_store::open(Box::new(mem.clone())); + assert!(opened.warnings.is_empty(), "{:?}", opened.warnings); + assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), atn); + assert_eq!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), legacy()); + assert!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); +} + +#[test] +fn backend_migration_failure_preserves_json_and_keeps_definitions_in_memory() { + struct NoMigration(MemBackend); + impl PresetBackend for NoMigration { + fn list(&self) -> Result, String> { + self.0.list() + } + fn read(&self, name: &str, max: u64) -> Result, String> { + self.0.read(name, max) + } + fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + self.0.write(name, bytes) + } + fn remove(&self, name: &str) -> Result<(), String> { + self.0.remove(name) + } + } + let mem = MemBackend::default(); + mem.files.lock().unwrap().insert(LEGACY_ACTIONS_FILE.into(), legacy()); + let opened = preset_store::open(Box::new(NoMigration(mem.clone()))); + assert!(matches!(opened.actions, preset_store::LoadedActions::MigrationBlocked(_))); + assert_eq!(opened.actions[0].name, "Legacy"); + let mut session = crate::Session::new(); + session.attach_preset_store(opened); + session.actions.list[0].name = "Memory edit".into(); + session.actions.rev += 1; + session.sync_preset_store(); + assert_eq!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), legacy()); + assert!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); + assert!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); +} + +#[test] +fn original_native_json_text_survives_an_unrelated_name_edit() { + let text = " { \"x\" : 2.0, \"future\" : [true, null] }\n"; + let file = AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Native text"), + items: vec![AtnItem { + event: AtnEvent::name("photocraftCommand"), + descriptor: Some( + Descriptor::new("null").with("command", D::Text(UnicodeString::new("future.command"))).with("params", D::Text(UnicodeString::new(text))), + ), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let bytes = atn::write_sets(&[file]).unwrap(); + let mut state = library::decode(&bytes).unwrap(); + assert_eq!(state.list[0].steps[0].1, json!({"x": 2.0, "future": [true, null]})); + assert_eq!(encode(&state), bytes); + state.list[0].name = "Renamed".into(); + let output = encode(&state); + let old = atn::parse_sets_with_spans(&bytes).unwrap(); + let new = atn::parse_sets_with_spans(&output).unwrap(); + assert_eq!(&output[new.spans[0].actions[0].items[0].range.clone()], &bytes[old.spans[0].actions[0].items[0].range.clone()]); +} + +#[test] +fn shortcut_change_without_action_revision_is_persisted() { + let mem = MemBackend::default(); + mem.files.lock().unwrap().insert(ACTIONS_FILE.into(), close_archive()); + let mut session = crate::Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + let revision = session.actions.rev; + session.edit_prefs(|prefs| { + prefs.shortcuts.insert(format!("{}Save and close", super::PLAY_SHORTCUT_PREFIX), "Cmd+Shift+F9".into()); + }); + session.sync_preset_store(); + assert_eq!(session.actions.rev, revision); + let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let files = atn::parse_sets(&bytes).unwrap(); + let action = &files[0].actions[0]; + assert_eq!((action.function_key, action.shift, action.command), (9, true, true)); +} diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 8f38b142d56..ccb42693d7a 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -1169,7 +1169,7 @@ fn actions_persist_with_the_preset_store() { let names: Vec = early.actions.list.iter().map(|a| a.name.clone()).collect(); assert!(names.contains(&"Early".into()) && names.contains(&"Kept".into()), "{names:?}"); - std::fs::write(dir.join("actions.json"), b"{").unwrap(); + std::fs::write(dir.join(crate::preset_store::ACTIONS_FILE), b"broken ATN").unwrap(); let mut bad = Session::new(); let warnings = bad.attach_preset_store(crate::preset_store::open_dir(&dir)); assert!(warnings.iter().any(|w| w.contains("actions")), "{warnings:?}"); @@ -1292,7 +1292,7 @@ fn arrange_that_cannot_move_is_a_noop_in_playback() { } /// #10459 review: recording into an existing set keeps that set's exact name, even one with -/// surrounding spaces (from an older actions.json); a new set name is trimmed. +/// surrounding spaces (from a legacy library); a new set name is trimmed. #[test] fn record_keeps_an_existing_sets_exact_name() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index d779b91592d..c0d506520c3 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -269,7 +269,7 @@ fn new_set(s: &mut Session, p: &Value) -> Result { if names.contains(&name) { return Err(bad(cmd, format!("a set named `{name}` already exists"))); } - s.actions.sets.push(name.clone()); + crate::atn_cmds::sidecar::install_row(&mut s.actions, &name, None); s.actions.touch(); Ok(json!({"set": name})) } diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42d64a45047..6ec7795a9f2 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -28,7 +28,7 @@ use crate::{EngineError, Result, Session}; mod adjustments; mod convert; -pub(crate) use convert::UniqueNames; +pub(crate) use convert::{UniqueNames, library_action, library_items}; mod descriptors; mod events; mod fields; diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index e167880c1c0..9b9c6515c33 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -79,13 +79,24 @@ pub(super) fn clip_name(s: &str) -> String { s.chars().take(MAX_NAME_CHARS).collect() } -fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Vec, total: &mut usize) -> std::result::Result { +fn convert_action( + file: &AtnFile, + a: &AtnAction, + set: &str, + unsupported: &mut Vec, + total: &mut usize, + mut cohorts: Option<&mut Vec>>, +) -> std::result::Result { let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); action.color = color_from_atn(a.color); for (i, item) in a.items.iter().enumerate() { - let mapped = if super::sidecar::has_payload(item) { + let native = read_native(item)?; + let is_native = native.is_some(); + let mapped = if let Some(step) = native { + Mapped::Steps(vec![step]) + } else if super::sidecar::has_payload(item) { // An unrecognized metadata-bearing Stop stays visible/opaque, never silently loses its payload. Mapped::Unsupported { harmless: false } } else { @@ -106,12 +117,15 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve (vec![photoshop_step(item, label)], item.enabled && !harmless) } }; + let start = action.steps.len(); for mut s in new_steps { *total = total.checked_add(1).filter(|total| *total <= atn::MAX_TOTAL_ITEMS).ok_or("too many converted steps in actions archive")?; if action.steps.len() >= atn::MAX_ITEMS { return Err("too many converted steps in one action".into()); } - normalise(&mut s.1); + if !is_native { + normalise(&mut s.1); + } let idx = action.steps.len(); action.steps.push(s); if !enabled { @@ -121,10 +135,95 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve action.dialogs.insert(idx); } } + if let Some(cohorts) = cohorts.as_deref_mut() { + cohorts.push(start..action.steps.len()); + } } Ok(action) } +/// Strict library projection retains full display strings; raw origins retain exact UTF16 bytes. +pub(crate) fn library_action(file: &AtnFile, action: &AtnAction, set: &str) -> std::result::Result<(Action, Vec>), String> { + let mut cohorts = Vec::new(); + let mut unsupported = Vec::new(); + let mut total = 0; + let mut value = convert_action(file, action, set, &mut unsupported, &mut total, Some(&mut cohorts))?; + value.name = action.name.to_string_lossy(); + value.set = (!set.is_empty()).then(|| set.to_string()); + // The durable library reflects its wire flags. The external importer may deliberately + // switch harmless unsupported steps off; bind_import treats that as an actual flag edit. + for (item, cohort) in action.items.iter().zip(&cohorts) { + for index in cohort.clone() { + if item.enabled { + value.disabled.remove(&index); + } else { + value.disabled.insert(index); + } + } + } + Ok((value, cohorts)) +} + +const NATIVE_EVENT: &str = "photocraftCommand"; + +fn native_text(descriptor: &Descriptor, key: &str) -> std::result::Result { + let mut values = descriptor.items.iter().filter(|(id, _)| id.is(key)); + let Some((_, D::Text(text))) = values.next() else { return Err(format!("native action step needs TEXT {key}")) }; + if values.next().is_some() || text.0.len() > crate::preset_store::MAX_ACTIONS_BYTES as usize { + return Err(format!("duplicate or oversized native action {key}")); + } + let units = text.0.strip_suffix(&[0]).unwrap_or(&text.0); + String::from_utf16(units).map_err(|_| format!("invalid UTF16 native action {key}")) +} + +fn read_native(item: &AtnItem) -> std::result::Result, String> { + if item.event != AtnEvent::name(NATIVE_EVENT) { + return Ok(None); + } + let descriptor = item.descriptor.as_ref().ok_or("missing native action descriptor")?; + if !descriptor.class_id.is("null") || descriptor.items.len() != 2 { + return Err("unsupported native action descriptor".into()); + } + let id = native_text(descriptor, "command")?; + if id.is_empty() { + return Err("empty native action command".into()); + } + let params = native_text(descriptor, "params")?; + // serde's bounded recursion remains enabled; load never dispatches or resolves the command. + let params = serde_json::from_str(¶ms).map_err(|error| format!("invalid native action JSON: {error}"))?; + Ok(Some((id, params))) +} + +pub(crate) fn native_item(id: &str, params: &Value) -> std::result::Result { + let params = serde_json::to_string(params).map_err(|error| error.to_string())?; + if id.is_empty() || id.len() > crate::preset_store::MAX_ACTIONS_BYTES as usize || params.len() > crate::preset_store::MAX_ACTIONS_BYTES as usize { + return Err("empty or oversized native action step".into()); + } + Ok(AtnItem { + event: AtnEvent::name(NATIVE_EVENT), + dictionary: id.as_bytes().to_vec(), + descriptor: Some(desc("null").with("command", D::Text(UnicodeString::new(id))).with("params", D::Text(UnicodeString::new(¶ms)))), + ..Default::default() + }) +} + +/// Use a Photoshop event only when it preserves this exact native step shape/options. +pub(crate) fn library_items(id: &str, params: &Value, set: &str) -> std::result::Result, String> { + if id == PHOTOSHOP_STEP { + return export_items(id, params, set).ok_or_else(|| "invalid kept Photoshop step".to_string()); + } + if let Some(items) = export_items(id, params, set) { + let file = AtnFile { name: UnicodeString::new_nul(set), ..Default::default() }; + let action = AtnAction { items: items.clone(), ..Default::default() }; + if let Ok((roundtrip, _)) = library_action(&file, &action, set) { + if roundtrip.steps == [(id.to_string(), params.clone())] { + return Ok(items); + } + } + } + Ok(vec![native_item(id, params)?]) +} + /// Convert a `.atn` file. Fails only when the file is unreadable: every step imports, as a /// command or as a kept Photoshop step. pub fn import(bytes: &[u8]) -> std::result::Result { @@ -170,7 +269,7 @@ pub fn import_with_encoding(bytes: &[u8], encoding: Option<&str>) -> std::result } let start = out.actions.len(); for (at, action) in file.actions.iter().enumerate() { - let mut converted = convert_action(file, action, &set, &mut out.unsupported, &mut total)?; + let mut converted = convert_action(file, action, &set, &mut out.unsupported, &mut total, None)?; if let Some(value) = metadata.actions.get_mut(index).and_then(|actions| actions.get_mut(at)).and_then(Option::take) { super::sidecar::apply(&mut converted, value); } @@ -234,7 +333,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option Option> { +pub(super) fn export_items(id: &str, params: &Value, set: &str) -> Option> { let item = export_item(id, params, set)?; if id != "layer.setProps" { return Some(vec![item]); @@ -280,7 +379,7 @@ pub fn parse_function_key(key: &str) -> Option<(u16, bool, bool)> { } /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key -/// (`F1`…`F15`, with `Shift+` and `Cmd+` as [`function_key_name`] writes them). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +/// (`F1`…`F15`, with `Shift+` and `Cmd+` as [`function_key_name`] writes them). PhotoCraft-only steps use the reserved native event, or are omitted with `skip_unsupported`. pub fn export( set: &str, actions: &[&Action], @@ -290,7 +389,7 @@ pub fn export( if actions.len() > atn::MAX_TOTAL_ACTIONS { return Err("too many actions in actions archive".into()); } - let selection = super::sidecar::Selection { actions: actions.to_vec(), source: super::sidecar::source_for(actions)? }; + let selection = super::sidecar::Selection { actions: actions.to_vec(), source: super::sidecar::source_for(actions)?, raw: None }; export_groups(&[(set, selection)], function_keys, skip_unsupported) } @@ -322,8 +421,10 @@ pub(super) fn export_groups( for (set, selection) in groups { files.push(export_set(set, &selection.actions, function_keys, skip_unsupported, &mut skipped, &mut written)?); } - let selected: Vec<_> = - groups.iter().map(|(_, selection)| super::sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone() }).collect(); + let selected: Vec<_> = groups + .iter() + .map(|(_, selection)| super::sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone(), raw: selection.raw.clone() }) + .collect(); super::sidecar::append(&mut files, &selected)?; let bytes = atn::write_sets(&files).map_err(|error| error.to_string())?; Ok((bytes, skipped)) @@ -346,21 +447,23 @@ fn export_set( (out.function_key, out.shift, out.command) = (n, shift, command); } for (i, (id, params)) in a.steps.iter().enumerate() { - let Some(items) = export_items(id, params, set) else { - if skip_unsupported { - skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); - continue; + // Portable export keeps the existing Photoshop mapping contract (including its + // explicit defaults). The durable library uses the stricter library_items path. + let items = match export_items(id, params, set) { + Some(items) => items, + None if id == PHOTOSHOP_STEP => { + if skip_unsupported { + skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); + continue; + } + return Err("invalid kept Photoshop step".into()); } - let step_name = match params.get("layer") { - Some(Value::Number(n)) => format!("`{id}` with numeric layer {n}"), - _ => format!("`{id}`"), - }; - return Err(format!( - "step {} of `{}` ({step_name}) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", - i + 1, - a.name - )); + None => vec![native_item(id, params)?], }; + if skip_unsupported && items.iter().any(|item| item.event == AtnEvent::name(NATIVE_EVENT)) { + skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); + continue; + } for mut item in items { *written = written.checked_add(1).filter(|count| *count <= atn::MAX_TOTAL_ITEMS).ok_or("too many output steps in actions archive")?; item.enabled = a.step_enabled(i); diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 562fa6dac4d..ec2e1557e96 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -15,6 +15,17 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { }; let (bytes, _) = crate::preset_import_cmds::file_bytes(p, cmd, ".atn file")?; let mut imp = import_with_encoding(&bytes, encoding).map_err(|e| bad(cmd, e))?; + // Legacy imports still use the existing conversion route. Version16 additionally keeps + // raw occurrences; normalization of the external import remains a deliberate edit. + let provenance = crate::actions_cmds::library::decode(&bytes).ok(); + if let Some(raw) = &provenance { + for (action, original) in imp.actions.iter_mut().zip(&raw.list) { + crate::actions_cmds::library::bind_import(action, original); + } + for (index, set) in imp.sets.iter_mut().enumerate() { + set.metadata = sidecar::empty_source(raw, index); + } + } match p.get("set") { None | Some(Value::Null) => {} Some(Value::String(n)) if !n.trim().is_empty() => { @@ -69,7 +80,7 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { drop(old_groups); let previous_rows: std::collections::HashSet = s.actions.sets.iter().filter(|name| targets.contains(*name)).cloned().collect(); let old_rows = s.actions.sets.iter().filter(|name| !replace || !targets.contains(*name)).count(); - let new_rows = if imp.sets.iter().any(|set| set.metadata.is_some()) { + let new_rows = if provenance.is_some() || imp.sets.iter().any(|set| set.metadata.is_some()) { imp.sets.len() } else { imp.sets @@ -150,9 +161,13 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { sidecar::retire_sets(&mut s.actions, &targets); } s.actions.list.extend(added); - for set in imp.sets { - if set.metadata.is_some() { + for (index, set) in imp.sets.into_iter().enumerate() { + if provenance.is_some() || set.metadata.is_some() { + let slot = s.actions.sets.len(); sidecar::install_row(&mut s.actions, &set.name, set.metadata); + if let Some(raw) = &provenance { + crate::actions_cmds::library::import_row(&mut s.actions, raw, index, slot); + } } else if (set.actions.is_empty() || previous_rows.contains(&set.name)) && !s.actions.sets.contains(&set.name) { sidecar::install_row(&mut s.actions, &set.name, None); } @@ -190,7 +205,7 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { let set_param = match p.get("set") { None | Some(Value::Null) => None, // A set is found by its exact name, as `actions.record` finds it: one loaded from a - // Photoshop file or an older actions.json may have surrounding spaces. Others are trimmed. + // Photoshop file or a legacy library may have surrounding spaces. Others are trimmed. Some(Value::String(n)) if crate::actions_edit_cmds::set_names(s).contains(n) => Some(n.clone()), Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), @@ -227,7 +242,9 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { let steps: usize = groups.iter().flat_map(|(_, selection)| &selection.actions).map(|action| action.steps.len()).sum(); let borrowed: Vec<_> = groups .iter() - .map(|(name, selection)| (name.as_str(), sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone() })) + .map(|(name, selection)| { + (name.as_str(), sidecar::Selection { actions: selection.actions.clone(), source: selection.source.clone(), raw: selection.raw.clone() }) + }) .collect(); let (bytes, skipped) = export_groups(&borrowed, &keys, skip).map_err(|error| bad(cmd, error))?; let steps = steps.saturating_sub(skipped.len()); diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs index 0995c1e780b..e450c115139 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -188,7 +188,7 @@ fn objects(entries: &[Vec]) -> Result> { } #[derive(Clone, Debug, PartialEq, Eq)] -struct Source { +pub(crate) struct Source { wrapper: Vec, outer: RawDescriptor, root: RawDescriptor, @@ -237,6 +237,8 @@ pub(super) fn empty_source(state: &ActionState, slot: usize) -> Option>) { let slot = state.sets.len(); + state.library.rows.resize(slot, None); + state.library.rows.push(None); state.sets.push(name.to_string()); if let Some(source) = source { state.atn_set_rows.push(SetRowMetadata { slot, row: name.to_string(), source }); @@ -253,6 +255,9 @@ pub(crate) fn duplicate_set_rows(state: &mut ActionState, source: &str, name: &s copied.push(group_source(state, source)); } let count = copied.len(); + state.library.rows.resize(state.sets.len(), None); + let raw: Vec<_> = if rows.is_empty() { vec![None] } else { rows.iter().map(|&slot| state.library.rows.get(slot).cloned().flatten()).collect() }; + state.library.rows.splice(at..at, raw); for entry in &mut state.atn_set_rows { if entry.slot >= at { entry.slot = entry.slot.saturating_add(count); @@ -271,6 +276,7 @@ pub(crate) fn retire_sets(state: &mut ActionState, names: &std::collections::Has let metadata: std::collections::BTreeMap = std::mem::take(&mut state.atn_set_rows).into_iter().map(|entry| (entry.slot, entry)).collect(); let rows = std::mem::take(&mut state.sets); + let origins = std::mem::take(&mut state.library.rows); for (old, row) in rows.into_iter().enumerate() { if names.contains(&row) { continue; @@ -279,6 +285,7 @@ pub(crate) fn retire_sets(state: &mut ActionState, names: &std::collections::Has if let Some(entry) = metadata.get(&old).filter(|entry| entry.row == row) { state.atn_set_rows.push(SetRowMetadata { slot, row: row.clone(), source: entry.source.clone() }); } + state.library.rows.push(origins.get(old).cloned().flatten()); state.sets.push(row); } } @@ -319,16 +326,17 @@ pub(crate) fn assign_set_metadata(state: &mut ActionState, action: usize, target } } -pub(super) fn selection<'a>(state: &ActionState, action: &'a Action) -> Selection<'a> { +pub(crate) fn selection<'a>(state: &ActionState, action: &'a Action) -> Selection<'a> { let source = action.atn_metadata.as_ref().and_then(|metadata| metadata.set.clone()).or_else(|| group_source(state, super::logical_set_name(action.set.as_deref()))); - Selection { actions: vec![action], source } + Selection { actions: vec![action], source, raw: state.library.row_source(&state.sets, action) } } -pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result)>> { +pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result)>> { if state.list.len() > atn::MAX_TOTAL_ACTIONS || state.sets.len() > atn::MAX_SETS { return Err("action library exceeds archive limits".into()); } + let raw_rows = state.library.row_index(&state.sets); let mut names = std::collections::HashSet::new(); let mut out: Vec<(&str, Selection<'a>)> = Vec::new(); for name in state.list.iter().map(|action| super::logical_set_name(action.set.as_deref())).chain(state.sets.iter().map(String::as_str)) { @@ -339,8 +347,10 @@ pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result true, (Some(left), Some(right)) => same_set(left, right), @@ -353,16 +363,25 @@ pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result= atn::MAX_SETS { return Err("too many sets in actions archive".into()); } - out.push((name, Selection { actions: vec![action], source })); + out.push((name, Selection { actions: vec![action], source, raw })); } } for (slot, _) in state.sets.iter().enumerate().filter(|(_, row)| row.as_str() == name) { + let raw = state.library.rows.get(slot).cloned().flatten(); + if let Some(raw) = raw.as_ref() { + if !out.iter().skip(before).any(|(_, selection)| selection.raw.as_ref() == Some(raw)) { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); + } + out.push((name, Selection { actions: Vec::new(), source: empty_source(state, slot), raw: Some(raw.clone()) })); + } + } if let Some(source) = empty_source(state, slot) { if !out.iter().skip(before).any(|(_, selection)| selection.source.as_ref().is_some_and(|existing| same_set(existing, &source))) { if out.len() >= atn::MAX_SETS { return Err("too many sets in actions archive".into()); } - out.push((name, Selection { actions: Vec::new(), source: Some(source) })); + out.push((name, Selection { actions: Vec::new(), source: Some(source), raw: state.library.rows.get(slot).cloned().flatten() })); } } } @@ -370,7 +389,7 @@ pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result= atn::MAX_SETS { return Err("too many sets in actions archive".into()); } - out.push((name, Selection { actions: Vec::new(), source: fallback })); + out.push((name, Selection { actions: Vec::new(), source: fallback, raw: None })); } } // Preserve physical rows only when doing so preserves the panel's group/action order. @@ -382,10 +401,17 @@ pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result = + state.library.rows.iter().enumerate().filter_map(|(slot, source)| Some(((state.sets.get(slot)?.as_str(), source.as_ref()?.key()), slot))).collect(); let mut physical: Vec<_> = (0..out.len()).collect(); physical.sort_by_cached_key(|&index| { let (name, selection) = &out[index]; - selection.source.as_ref().and_then(|source| slots.get(&(*name, Arc::as_ptr(&source.source), source.ordinal)).copied()).unwrap_or(usize::MAX) + selection + .raw + .as_ref() + .and_then(|raw| raw_slots.get(&(*name, raw.key())).copied()) + .or_else(|| selection.source.as_ref().and_then(|source| slots.get(&(*name, Arc::as_ptr(&source.source), source.ordinal)).copied())) + .unwrap_or(usize::MAX) }); if panel_projection(&out, 0..out.len()) != panel_projection(&out, physical.iter().copied()) { return Ok(out); @@ -394,7 +420,7 @@ pub(super) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result(groups: &[(&'a str, Selection<'a>)], order: impl IntoIte nonempty.into_iter().map(|name| (name, actions.remove(name).unwrap_or_default())).collect() } -pub(super) struct Decoded { - pub(super) hidden: Option, - pub(super) sets: Vec>>, - pub(super) actions: Vec>>, +pub(crate) struct Decoded { + pub(crate) hidden: Option, + pub(crate) sets: Vec>>, + pub(crate) actions: Vec>>, + pub(crate) source: Option>, } fn unique<'a>(descriptor: &'a Descriptor, key: &str) -> Result<&'a D> { @@ -466,8 +493,33 @@ fn payload(file: &AtnFile) -> Option<&[u8]> { bytes.get(4..) } +pub(crate) fn is_wrapper(file: &AtnFile) -> bool { + payload(file).is_some() +} + +pub(crate) fn legacy_origin(source: Option<&Source>) -> Result> { + let Some(source) = source else { return Ok(None) }; + let root = source.root.parsed()?; + let mut fields = root.items.iter().filter(|(id, _)| id.is("photocraftLegacyJsonV1")); + let Some((_, value)) = fields.next() else { return Ok(None) }; + if fields.next().is_some() { + return Err("duplicate legacy Actions origin".into()); + } + let D::Descriptor(value) = value else { return Err("invalid legacy Actions origin".into()) }; + crate::actions_cmds::library::LegacyOrigin::from_descriptor(value).map(Some) +} + pub(super) fn decode(files: &[AtnFile]) -> Result { - let mut result = Decoded { hidden: None, sets: vec![None; files.len()], actions: files.iter().map(|file| vec![None; file.actions.len()]).collect() }; + decode_inner(files, false, None) +} + +pub(crate) fn decode_library(files: &[AtnFile], raw_outer: Option<&[u8]>) -> Result { + decode_inner(files, true, raw_outer) +} + +fn decode_inner(files: &[AtnFile], library: bool, raw_outer: Option<&[u8]>) -> Result { + let mut result = + Decoded { hidden: None, sets: vec![None; files.len()], actions: files.iter().map(|file| vec![None; file.actions.len()]).collect(), source: None }; for (hidden, file) in files.iter().enumerate() { let Some(bytes) = payload(file) else { continue }; if hidden.checked_add(1) != Some(files.len()) { @@ -482,7 +534,7 @@ pub(super) fn decode(files: &[AtnFile]) -> Result { return Err("unsupported Actions sidecar root".into()); } let sets = root.list("sets")?; - if sets.is_empty() { + if sets.is_empty() && !library { return Err("Actions sidecar requires at least one real set".into()); } if sets.len().checked_add(1) != Some(files.len()) { @@ -493,14 +545,18 @@ pub(super) fn decode(files: &[AtnFile]) -> Result { unique(descriptor, MODERN)?; unique(descriptor, "Msge")?; unique(descriptor, "Cntn")?; - let outer = RawDescriptor::new(&descriptor.to_bytes())?; + let outer = match raw_outer { + Some(bytes) => RawDescriptor::new(bytes)?, + None => RawDescriptor::new(&descriptor.to_bytes())?, + }; let outer_signature = outer.patch(&[(MODERN, Some(value_bytes(D::RawData(Vec::new()))?))])?; let mut signature_file = file.clone(); let signature_item = signature_file.actions.first_mut().and_then(|action| action.items.first_mut()).ok_or("missing sidecar signature item")?; signature_item.descriptor = Some(Descriptor::from_bytes(&outer_signature).map_err(|error| error.to_string())?); let mut signature = atn::write(&signature_file).map_err(|error| error.to_string())?; - signature.extend(root.patch(&[("sets", Some(objects(&[])?))])?); + signature.extend(root.patch(&[("sets", Some(objects(&[])?)), ("photocraftLegacyJsonV1", None)])?); let source = Arc::new(Source { wrapper: atn::write(file).map_err(|error| error.to_string())?, outer, root, signature }); + result.source = Some(source.clone()); let mut ordinal = 0usize; for (index, real) in files.iter().enumerate().filter(|(index, _)| *index != hidden) { let raw = sets.get(ordinal).ok_or("missing positional sidecar set")?.clone(); @@ -562,7 +618,7 @@ pub(super) fn decode(files: &[AtnFile]) -> Result { Ok(result) } -pub(super) fn apply(action: &mut Action, metadata: ActionMetadata) { +pub(crate) fn apply(action: &mut Action, metadata: ActionMetadata) { action.description = metadata.description.clone(); action.icon = metadata.icon.clone(); action.atn_metadata = Some(metadata); @@ -598,9 +654,10 @@ fn entry(action: &Action) -> Result> { .to_bytes()) } -pub(super) struct Selection<'a> { - pub(super) actions: Vec<&'a Action>, - pub(super) source: Option>, +pub(crate) struct Selection<'a> { + pub(crate) actions: Vec<&'a Action>, + pub(crate) source: Option>, + pub(crate) raw: Option, } pub(super) fn source_for(actions: &[&Action]) -> Result>> { @@ -618,6 +675,26 @@ pub(super) fn source_for(actions: &[&Action]) -> Result> } pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Result<()> { + append_inner(files, selected, false, None, None).map(|_| ()) +} + +pub(crate) fn append_library( + files: &mut Vec, + selected: &[Selection<'_>], + source: Option>, + receipt: Option<&crate::actions_cmds::library::LegacyOrigin>, +) -> Result>> { + let force = files.is_empty() || receipt.is_some() || source.is_some(); + append_inner(files, selected, force, source, receipt) +} + +fn append_inner( + files: &mut Vec, + selected: &[Selection<'_>], + force: bool, + source: Option>, + receipt: Option<&crate::actions_cmds::library::LegacyOrigin>, +) -> Result>> { if files.len() != selected.len() { return Err("sidecar export selection mismatch".into()); } @@ -625,8 +702,8 @@ pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Re selection.source.is_some() || selection.actions.iter().any(|action| action.atn_metadata.is_some() || !action.description.is_empty() || !action.icon.is_empty()) }); - if !needed { - return Ok(()); + if !needed && !force { + return Ok(None); } let actions = files.iter().try_fold(1usize, |count, file| count.checked_add(file.actions.len())).ok_or("sidecar action count overflow")?; let steps = files @@ -637,7 +714,7 @@ pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Re if files.len() >= atn::MAX_SETS || actions > atn::MAX_TOTAL_ACTIONS || steps > atn::MAX_TOTAL_ITEMS { return Err("actions archive leaves no space for its metadata sidecar".into()); } - let mut source: Option> = None; + let mut source: Option> = source; let mut sets = Vec::with_capacity(selected.len()); let mut set_bytes = 8usize; for selection in selected { @@ -685,6 +762,11 @@ pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Re Some(source) => source.root.patch(&[("sets", Some(objects(&sets)?))])?, None => RawDescriptor::new(&Descriptor::new("null").with("sets", D::List(Vec::new())).to_bytes())?.patch(&[("sets", Some(objects(&sets)?))])?, }; + let root = if let Some(receipt) = receipt { + RawDescriptor::new(&root)?.patch(&[("photocraftLegacyJsonV1", Some(value_bytes(D::Descriptor(receipt.descriptor()?))?))])? + } else { + root + }; let mut payload = 16u32.to_be_bytes().to_vec(); extend(&mut payload, &root)?; let outer = match &source { @@ -719,7 +801,7 @@ pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Re let item = wrapper.actions.first_mut().and_then(|action| action.items.first_mut()).ok_or("missing output sidecar wrapper")?; item.descriptor = Some(descriptor); files.push(wrapper); - Ok(()) + Ok(Some(outer)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs b/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs index bb749772da3..2a5a0002542 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar_tests.rs @@ -500,7 +500,8 @@ fn sidecar_overhead_is_reserved_before_appending_a_physical_set() { let mut action = Action::new("Described", vec![]); action.description = "Details".into(); let mut files = vec![AtnFile::default(); atn::MAX_SETS]; - let selections: Vec<_> = (0..atn::MAX_SETS).map(|index| Selection { actions: if index == 0 { vec![&action] } else { vec![] }, source: None }).collect(); + let selections: Vec<_> = + (0..atn::MAX_SETS).map(|index| Selection { actions: if index == 0 { vec![&action] } else { vec![] }, source: None, raw: None }).collect(); assert!(append(&mut files, &selections).is_err()); assert_eq!(files.len(), atn::MAX_SETS); diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f468f724329..843746ee665 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3,6 +3,53 @@ use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; use serde_json::json; +/// Unsupported Photoshop mappings remain unsupported. Native fallback must retain exact +/// command/params rather than emitting a plausible Photoshop event with missing options. +fn native_fallback(set: &str, actions: &[&Action], keys: &[(String, String)]) -> bool { + assert!(actions.iter().flat_map(|action| &action.steps).any(|(id, params)| export_item(id, params, set).is_none())); + let (bytes, skipped) = export(set, actions, keys, false).unwrap(); + assert!(skipped.is_empty()); + let files = atn::parse_sets(&bytes).unwrap(); + let imported = import(&bytes).unwrap(); + for (action, (record, loaded)) in actions.iter().zip(files[0].actions.iter().zip(&imported.actions)) { + let mut next = 0; + for (id, params) in &action.steps { + if let Some(items) = super::convert::export_items(id, params, set) { + next += items.len(); + } else { + let item = &record.items[next]; + assert_eq!(item.event, AtnEvent::name("photocraftCommand")); + let descriptor = item.descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("command"), Some(&D::Text(UnicodeString::new(id)))); + let Some(D::Text(text)) = descriptor.get("params") else { panic!("missing native params") }; + assert_eq!(serde_json::from_str::(&text.to_string_lossy()).unwrap(), *params); + assert!(loaded.steps.iter().any(|step| step == &(id.clone(), params.clone()))); + next += 1; + } + } + assert_eq!(next, record.items.len()); + } + true +} + +fn saved_native(s: &mut Session, name: &str) -> Value { + let expected = s.actions.list.iter().find(|action| action.name == name).unwrap().clone(); + assert!(native_fallback("Native fallback", &[&expected], &[])); + let saved = s.execute("actions.save", json!({"action": name})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert!( + atn::parse_sets(&bytes) + .unwrap() + .iter() + .flat_map(|set| &set.actions) + .flat_map(|action| &action.items) + .any(|item| item.event == AtnEvent::name("photocraftCommand")) + ); + assert_eq!(import(&bytes).unwrap().actions[0].steps, expected.steps); + saved +} + #[test] fn blend_modes_accept_photoshop_string_ids_and_export_canonical_ids() { for (string_id, char_id, label) in [ @@ -374,10 +421,9 @@ fn save_round_trips_through_photoshop_events() { t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); assert_eq!(t.actions.list, first); - // A step with no Photoshop equivalent fails unless skipped. + // A step with no Photoshop equivalent is kept natively unless explicitly skipped. s.actions.list.push(Action::new("Ours", vec![("unsupported.forTest".into(), json!({}))])); - let e = s.execute("actions.save", json!({"action": "Ours"})).unwrap_err().to_string(); - assert!(e.contains("unsupported.forTest"), "{e}"); + saved_native(&mut s, "Ours"); let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap(); assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "unsupported.forTest"}])); assert_eq!(r["set"], "Ours"); @@ -420,8 +466,7 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { // the command reads it. let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), json!({"colors": "reds", "cyan": "35"}))])); - let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); - assert!(e.contains("image.adjustments.selectiveColor"), "{e}"); + saved_native(&mut s, "A"); let nulled = back(json!({"colors": "reds", "cyan": 35, "magenta": null})); assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}"); @@ -444,9 +489,8 @@ fn a_setting_the_event_cannot_hold_is_refused() { let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(id.into(), params)])); let before = s.actions.list[0].steps.clone(); - let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); - assert!(e.contains(id), "{id}: {e}"); - // A refused save leaves the action as it was: the step is still there to be played. + saved_native(&mut s, "A"); + // Native fallback leaves the original step and its unsupported options unchanged. assert_eq!(s.actions.list[0].steps, before, "{id}"); let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap(); assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}"); @@ -2227,7 +2271,7 @@ fn smart_blur_quality_uses_photoshop_codes() { // An unknown quality or a non-string one is not exported. for bad in [json!({"quality": "best"}), json!({"quality": 3}), json!({"radius": "x"})] { let a = Action::new("Bad", vec![("filter.blur.smartBlur".into(), bad.clone())]); - assert!(export("S", &[&a], &[], false).is_err(), "{bad}"); + assert!(native_fallback("S", &[&a], &[]), "{bad}"); } } @@ -2297,7 +2341,7 @@ fn save_while_recording_includes_pending_steps() { fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() { for id in ["filter.other.minimum", "filter.other.maximum"] { let round = Action { name: "Round".into(), steps: vec![(id.into(), json!({"radius": 2, "preserve": "roundness"}))], ..Default::default() }; - assert!(export("Audit", &[&round], &[], false).is_err(), "{id}: strict export refuses it"); + assert!(native_fallback("Audit", &[&round], &[]), "{id}: Photoshop mapping remains unavailable"); let (_, skipped) = export("Audit", &[&round], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Round".into(), step: 0, id: id.into() }], "{id}"); for p in [json!({"radius": 2}), json!({"radius": 2, "preserve": "squareness"})] { @@ -2331,8 +2375,7 @@ fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry( json!({"warp": "arc"}), json!({"corners": [[0, 0], [10, 0], [10, 10], [0, 10]]}), ] { - let err = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_err(); - assert!(err.contains("no Photoshop equivalent") && err.contains(&format!("`{id}`")), "{id} {p}: {err}"); + assert!(native_fallback("Audit7", &[&one(id, p.clone())], &[])); let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: id.into() }], "{id} {p}"); } @@ -2348,7 +2391,7 @@ fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry( fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; for id in ["select.modify.feather", "select.modify.expand", "select.modify.contract", "select.modify.smooth"] { - assert!(export("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[], false).is_err(), "{id}"); + assert!(native_fallback("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[]), "{id}"); let plain = json!({"radius": 2, "applyAtCanvasBounds": false}); let (bytes, _) = export("Audit7", &[&one(id, plain)], &[], false).unwrap_or_else(|e| panic!("{id}: {e}")); assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![(id.to_string(), json!({"radius": 2}))]); @@ -2361,7 +2404,7 @@ fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { one("paint.bucket", p) }; for extra in [json!({"pattern": "AuditPattern"}), json!({"scale": 50}), json!({"angle": 30}), json!({"phase": [3, 5]})] { - assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_err(), "{extra}"); + assert!(native_fallback("Audit7", &[&bucket(extra.clone())], &[]), "{extra}"); } for extra in [json!({}), json!({"scale": 100, "angle": 0, "phase": [0, 0]})] { assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_ok(), "{extra}"); @@ -2380,7 +2423,7 @@ fn new_layer_options_the_descriptor_cannot_carry_refuse_strict_export() { json!({"name": "New", "position": "below"}), json!({"name": "New", "clipToPrevious": 1}), ] { - assert!(export("Audit7", &[&new_layer(p.clone())], &[], false).is_err(), "{p}"); + assert!(native_fallback("Audit7", &[&new_layer(p.clone())], &[]), "{p}"); let (_, skipped) = export("Audit7", &[&new_layer(p.clone())], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.new.layer".into() }], "{p}"); } @@ -2413,8 +2456,7 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() { ("filter.noise.addNoise", json!({"amount": 30, "seed": 4294967295_u64})), ]; for (id, p) in refused { - let err = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_err(); - assert!(err.contains(&format!("`{id}`")), "{id} {p}: the error names the step: {err}"); + assert!(native_fallback("Audit7", &[&one(id, p.clone())], &[])); let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: id.into() }], "{id} {p}"); } @@ -2471,8 +2513,7 @@ fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() { json!({"kind": "hueSaturation", "hue": 20}), json!({"kind": "hueSaturation", "reds": {"hue": 10}}), ] { - let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err(); - assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}"); + assert!(native_fallback("Audit8", &[&one(p.clone())], &[])); let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}"); } @@ -2487,7 +2528,7 @@ fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() { assert!(export("Audit8", &[&one(hue)], &[], false).is_ok()); // A colour range written without all of its own settings would reset the rest too. let range = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10}}); - assert!(export("Audit8", &[&one(range)], &[], false).is_err()); + assert!(native_fallback("Audit8", &[&one(range)], &[])); let range = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5}}); assert!(export("Audit8", &[&one(range)], &[], false).is_ok()); @@ -2504,7 +2545,7 @@ fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() { json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": "invalid"}}), json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [315, 345.5, 15, 45]}}), ] { - assert!(export("Audit8", &[&one(bad.clone())], &[], false).is_err(), "{bad}"); + assert!(native_fallback("Audit8", &[&one(bad.clone())], &[]), "{bad}"); } } @@ -2533,8 +2574,7 @@ fn partial_levels_edits_refuse_strict_export() { json!({"kind": "levels", "green": [0, 255]}), json!({"kind": "levels", "blue": {"outBlack": "3", "outWhite": "250"}}), ] { - let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err(); - assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}"); + assert!(native_fallback("Audit8", &[&one(p.clone())], &[])); let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}"); } @@ -2561,7 +2601,7 @@ fn partial_levels_edits_refuse_strict_export() { #[test] fn close_with_document_target_is_not_exported_as_plain_close() { let targeted = Action { name: "Probe".into(), steps: vec![("file.close".into(), json!({"document": 0}))], ..Default::default() }; - assert!(export("Audit4", &[&targeted], &[], false).is_err()); + assert!(native_fallback("Audit4", &[&targeted], &[])); let (_, skipped) = export("Audit4", &[&targeted], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "file.close".into() }]); let plain = Action { name: "Plain".into(), steps: vec![("file.close".into(), json!({}))], ..Default::default() }; @@ -2638,12 +2678,10 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))])); let before_actions = s.actions.list.clone(); let before_rev = s.active().unwrap().revision; - let err_delete = s.execute("actions.save", json!({"action": "DeleteNumeric"})); - assert!(err_delete.is_err(), "strict export must reject numeric layer ID for layer.delete rather than falling back to current target"); - assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails"); - assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails"); - let err_msg = err_delete.unwrap_err().to_string(); - assert!(err_msg.contains("(`layer.delete` with numeric layer 42)"), "error message should name the numeric layer: {err_msg}"); + saved_native(&mut s, "DeleteNumeric"); + assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged during native fallback"); + assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged during native fallback"); + assert_eq!(s.actions.list.iter().find(|action| action.name == "DeleteNumeric").unwrap().steps[0].1["layer"], 42); // Same rejection and state invariance for remaining commands with numeric layer reference for (name, id, params) in [ @@ -2665,9 +2703,9 @@ fn numeric_layer_references_refuse_silent_fallback_to_current_selection() { s.actions.list.push(Action::new(name, vec![(id.into(), params)])); let before_actions = s.actions.list.clone(); let before_rev = s.active().unwrap().revision; - assert!(s.execute("actions.save", json!({"action": name})).is_err()); - assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails"); - assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails"); + saved_native(&mut s, name); + assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged during native fallback"); + assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged during native fallback"); } // With skipUnsupported: true, numeric layer references are skipped @@ -2745,7 +2783,7 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))], ..Default::default() }; - assert!(export("Audit", &[&masked], &[], false).is_err()); + assert!(native_fallback("Audit", &[&masked], &[])); let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]); for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] { @@ -2763,7 +2801,7 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { #[test] fn duplicate_in_place_is_not_exported_as_plain_duplicate() { let in_place = Action { name: "Probe".into(), steps: vec![("layer.duplicate".into(), json!({"inPlace": true}))], ..Default::default() }; - assert!(export("Audit5", &[&in_place], &[], false).is_err()); + assert!(native_fallback("Audit5", &[&in_place], &[])); let (_, skipped) = export("Audit5", &[&in_place], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.duplicate".into() }]); for params in [json!({}), json!({"inPlace": false})] { @@ -2780,7 +2818,7 @@ fn duplicate_in_place_is_not_exported_as_plain_duplicate() { #[test] fn partial_play_action_is_not_exported_as_whole_action_call() { let partial = Action { name: "Parent".into(), steps: vec![("actions.play".into(), json!({"action": "Child", "from": 1}))], ..Default::default() }; - assert!(export("Set", &[&partial], &[], false).is_err()); + assert!(native_fallback("Set", &[&partial], &[])); let (_, skipped) = export("Set", &[&partial], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Parent".into(), step: 0, id: "actions.play".into() }]); for params in [json!({"action": "Child"}), json!({"action": "Child", "from": 0})] { @@ -2886,7 +2924,7 @@ fn apply_image_numeric_source_selectors_are_refused() { assert!(export_item("image.applyImage", &p, "S").is_none(), "{p}"); } let a = Action::new("A", vec![("image.applyImage".into(), json!({"sourceLayer": 3}))]); - assert!(export("S", &[&a], &[], false).is_err()); + assert!(native_fallback("S", &[&a], &[])); } /// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays @@ -3104,7 +3142,7 @@ fn transform_with_mask_or_channel_target_is_not_exported_as_layer_transform() { steps: vec![("edit.transform".into(), json!({"target": target, "dx": 3, "dy": 2, "scaleX": 100, "interpolation": "nearest"}))], ..Default::default() }; - assert!(export("Targets", &[&a], &[], false).is_err(), "{target} exported"); + assert!(native_fallback("Targets", &[&a], &[]), "{target} exported"); let (_, skipped) = export("Targets", &[&a], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "edit.transform".into() }]); } @@ -3130,7 +3168,7 @@ fn apply_image_with_blending_mask_is_not_exported_without_its_mask() { let source = json!({"layer": "Pixels", "channel": "green", "invert": true}); for params in [json!({"source": source, "blending": "normal", "mask": {"channel": 0}}), json!({"source": source, "blending": "normal", "maskChannel": 0})] { let masked = Action { name: "Masked".into(), steps: vec![("image.applyImage".into(), params.clone())], ..Default::default() }; - assert!(export("Audit5", &[&masked], &[], false).is_err(), "{params}"); + assert!(native_fallback("Audit5", &[&masked], &[]), "{params}"); let (_, skipped) = export("Audit5", &[&masked], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Masked".into(), step: 0, id: "image.applyImage".into() }]); } @@ -3162,7 +3200,7 @@ fn refine_edge_with_strokes_and_sample_all_layers_is_not_exported_without_the_st steps: vec![("select.refineEdge".into(), json!({"radius": 1, "sampleAllLayers": true, "refineStrokes": [stroke]}))], ..Default::default() }; - assert!(export("Audit", &[&brushed], &[], false).is_err()); + assert!(native_fallback("Audit", &[&brushed], &[])); let (_, skipped) = export("Audit", &[&brushed], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); let plain = Action { @@ -3183,7 +3221,7 @@ fn refine_edge_with_brush_strokes_is_not_exported_without_them() { steps: vec![("select.refineEdge".into(), json!({"radius": 1, "refineStrokes": [{"points": [[5, 4]], "size": 4, "mode": mode}]}))], ..Default::default() }; - assert!(export("Audit", &[&brushed], &[], false).is_err(), "{mode}"); + assert!(native_fallback("Audit", &[&brushed], &[]), "{mode}"); let (_, skipped) = export("Audit", &[&brushed], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); } @@ -3203,7 +3241,7 @@ fn refine_edge_with_workspace_edits_is_not_exported_without_them() { let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4}); for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] { let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() }; - assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}"); + assert!(native_fallback("Audit", &[&edited], &[]), "{params}"); let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]); } @@ -3256,8 +3294,7 @@ fn recorded_layer_select_exports_to_atn() { // If an action has an occurrence target (duplicate names), it cannot be represented unambiguously in .atn let bad_action = Action::new("Dup", vec![("layer.select".into(), json!({"name": "Wanted", "occurrence": 1}))]); - let err = export("Set", &[&bad_action], &[], false); - assert!(err.is_err(), "occurrence without representation must fail strict export"); + assert!(native_fallback("Set", &[&bad_action], &[]), "occurrence must never become an ambiguous Photoshop reference"); } #[test] @@ -3282,8 +3319,8 @@ fn select_rect_modes_export_without_becoming_replace() { s.actions.list.push(Action::new("Unsupported", vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":"xor"}))])); let before = s.actions.list.clone(); - assert!(s.execute("actions.save", json!({"action":"Unsupported"})).is_err()); - assert_eq!(s.actions.list, before, "rejected export must leave actions unchanged"); + saved_native(&mut s, "Unsupported"); + assert_eq!(s.actions.list, before, "native fallback must leave actions unchanged"); // With skipUnsupported: true, unrepresentable modes are reported in skipped let skipped = s.execute("actions.save", json!({"action": "Unsupported", "skipUnsupported": true})).unwrap(); @@ -3315,9 +3352,8 @@ fn radial_blur_off_center_strict_export_refuses_silent_recentering() { vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "centerX": 0.1, "centerY": 0.2}))], )); let before_actions = s.actions.list.clone(); - let err = s.execute("actions.save", json!({"action": "OffCenter"})); - assert!(err.is_err(), "strict export must reject off-center radial blur rather than silently recentering it"); - assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save"); + saved_native(&mut s, "OffCenter"); + assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged during native fallback"); // With skipUnsupported: true, off-center radial blur is skipped let skipped = s.execute("actions.save", json!({"action": "OffCenter", "skipUnsupported": true})).unwrap(); @@ -3329,9 +3365,8 @@ fn radial_blur_off_center_strict_export_refuses_silent_recentering() { .list .push(Action::new("MaskTarget", vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "target": "mask"}))])); let before_actions = s.actions.list.clone(); - let err_mask = s.execute("actions.save", json!({"action": "MaskTarget"})); - assert!(err_mask.is_err(), "strict export must reject radial blur with non-pixel target"); - assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save"); + saved_native(&mut s, "MaskTarget"); + assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged during native fallback"); let skipped_mask = s.execute("actions.save", json!({"action": "MaskTarget", "skipUnsupported": true})).unwrap(); assert_eq!(skipped_mask["steps"], 0); @@ -3356,7 +3391,7 @@ fn crop_with_output_size_or_resolution_is_not_exported_as_plain_crop() { json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6}), ] { let sized = Action { name: "Probe".into(), steps: vec![("image.crop".into(), params)], ..Default::default() }; - assert!(export("Audit", &[&sized], &[], false).is_err()); + assert!(native_fallback("Audit", &[&sized], &[])); let (_, skipped) = export("Audit", &[&sized], &[], true).unwrap(); assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "image.crop".into() }]); } @@ -3596,8 +3631,7 @@ fn loaded_camera_raw_filter_steps_save_back_unchanged() { // Edited, it no longer matches what was recorded. let original = s.actions.list[0].steps[0].1.clone(); s.actions.list[0].steps[0].1["exposure"] = json!(1.5); - let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string(); - assert!(e.contains("filter.cameraRaw"), "{e}"); + saved_native(&mut s, "A"); // Recorded by PhotoCraft: no descriptor to write back. s.actions.list[0].steps[0].1 = original; s.actions.list[0].steps[0].1.as_object_mut().unwrap().remove("__atnDescriptor"); @@ -3730,9 +3764,9 @@ fn save_as_into_a_folder_imports_as_a_folder_step_and_saves_back() { assert_eq!(import(&bytes).unwrap().actions[0].steps, steps); // A folder step without its extension, or as a copy, has no Photoshop event. let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"folder": "/out"}))]); - assert!(export("Saves", &[&odd], &[], false).is_err()); + assert!(native_fallback("Saves", &[&odd], &[])); let copy = Action::new("C", vec![("file.saveACopy".to_string(), json!({"folder": "/out", "extension": "png"}))]); - assert!(export("Saves", &[©], &[], false).is_err()); + assert!(native_fallback("Saves", &[©], &[])); } #[test] @@ -3759,7 +3793,7 @@ fn saves_round_trip_through_photoshop_events() { // A Save As PhotoCraft can't express in Photoshop's terms is skipped, never exported as a // plain Save. let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"path": "/out/result.xcf"}))]); - assert!(export("Saves", &[&odd], &[], false).is_err()); + assert!(native_fallback("Saves", &[&odd], &[])); } #[test] @@ -4376,7 +4410,7 @@ fn select_channel_export_rejects_nested_color_selections() { s.execute("channel.target", params.clone()).unwrap(); assert_eq!(s.active().unwrap().channel_view.selected_colors, colors); s.actions.list.push(Action::new("Nested", vec![("channel.target".into(), params.clone())])); - assert!(s.execute("actions.save", json!({"action":"Nested"})).is_err(), "{params} must not become a single Cyan selection"); + saved_native(&mut s, "Nested"); let skipped = s.execute("actions.save", json!({"action":"Nested","skipUnsupported":true})).unwrap(); assert_eq!(skipped["skipped"], json!([{"action":"Nested","step":0,"id":"channel.target"}])); let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap(); @@ -4401,7 +4435,7 @@ fn select_channel_export_rejects_mode_specific_gray_objects() { s.execute("file.new", json!({"width":2,"height":1,"mode":mode})).unwrap(); assert_eq!(s.execute("channel.target", params.clone()).is_ok(), mode == "gray"); s.actions.list.push(Action::new("Gray object", vec![("channel.target".into(), params.clone())])); - assert!(s.execute("actions.save", json!({"action":"Gray object"})).is_err(), "{mode}/{color} must not become composite"); + saved_native(&mut s, "Gray object"); let skipped = s.execute("actions.save", json!({"action":"Gray object","skipUnsupported":true})).unwrap(); assert_eq!(skipped["skipped"], json!([{"action":"Gray object","step":0,"id":"channel.target"}])); let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap(); @@ -5782,7 +5816,7 @@ fn pattern_fill_missing_everywhere_names_the_pattern() { } /// FredApps review on #10459: a set whose name has surrounding spaces (a Photoshop `.atn` or an -/// older actions.json can have one, and New Action records into it under that exact name) saves +/// a legacy library can have one, and New Action records into it under that exact name) saves /// by that name. `actions.save` used to trim it and then find no set. #[test] fn save_finds_a_set_by_its_exact_name_with_spaces() { diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index cc935564122..e430eaf2942 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -38,7 +38,7 @@ //! The pattern library (imported `.pat` and defined patterns, not the built-ins) lives there too: //! `patterns.json` plus one `.pat` file per pattern, see [`patterns`]. //! -//! The Actions list (`actions.json`, see [`crate::actions_cmds`]) lives in the same directory. +//! The Actions library (`actions.atn`, see [`crate::actions_cmds`]) lives in the same directory. //! A missing file is an empty list. A corrupt or oversized file is skipped with a warning. //! Headless sessions have no store, so their actions stay in memory. //! @@ -74,8 +74,11 @@ pub const MAX_GROUP_BYTES: u64 = 16 << 20; pub const MAX_TIP_BYTES: u64 = 64 << 20; /// Largest index file read. pub const MAX_INDEX_BYTES: u64 = 4 << 20; -/// The Actions list (`actions.json`). -pub const ACTIONS_FILE: &str = "actions.json"; +/// The sole durable Actions library. +pub const ACTIONS_FILE: &str = "actions.atn"; +/// Read only as migration input, never written by normal synchronization. +pub const LEGACY_ACTIONS_FILE: &str = "actions.json"; +pub const MIGRATED_ACTIONS_FILE: &str = "actions.json.migrated"; /// Largest Actions file read or written. pub const MAX_ACTIONS_BYTES: u64 = 16 << 20; /// The user's pattern library (`.pat`). @@ -97,6 +100,14 @@ const GROUP_FORMAT: &str = "photocraft-brush-group"; const TIP_MAGIC: &[u8; 6] = b"PCTIP1"; const TIP_HEADER: usize = 6 + 1 + 4 + 4; +/// Native completion, asynchronous staging, and post-publication retirement are distinct states. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ActionsMigration { + Stored, + Staged, + PublishedRetirementPending(String), +} + /// Where the store's files live. File names are relative (`name.pcbrushes`, `tips/x.pctip`). pub trait PresetBackend: Send { /// Every file in the store with its size in bytes (the top level plus [`TIPS_DIR`]). @@ -108,6 +119,12 @@ pub trait PresetBackend: Send { fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String>; /// Delete a file; deleting a missing file succeeds. fn remove(&self, name: &str) -> Result<(), String>; + /// Publish ATN without replacing an existing file, then retain the exact legacy input as backup. + /// `published` means ATN was already read and validated; it must still match `atn`. + /// A browser implementation stages the three names together and acknowledges its transaction. + fn migrate_actions(&self, _legacy: &[u8], _atn: &[u8], _published: bool) -> Result { + Err("this preset backend cannot safely migrate Actions".into()) + } } /// Store file names are generated (`[A-Za-z0-9 _.-]`, optionally under `tips/`); anything else @@ -148,12 +165,43 @@ impl PresetBackend for MemBackend { self.lock()?.insert(name.to_string(), bytes.to_vec()); Ok(()) } + fn migrate_actions(&self, legacy: &[u8], atn: &[u8], published: bool) -> Result { + let mut files = self.lock()?; + check_migration(&files, legacy, atn, published)?; + files.insert(ACTIONS_FILE.into(), atn.to_vec()); + if files.contains_key(MIGRATED_ACTIONS_FILE) { + return Ok(ActionsMigration::PublishedRetirementPending("legacy backup already exists; both original and backup were retained".into())); + } + files.insert(MIGRATED_ACTIONS_FILE.into(), legacy.to_vec()); + files.remove(LEGACY_ACTIONS_FILE); + Ok(ActionsMigration::Stored) + } fn remove(&self, name: &str) -> Result<(), String> { self.lock()?.remove(name); Ok(()) } } +fn check_migration(files: &std::collections::BTreeMap>, legacy: &[u8], atn: &[u8], published: bool) -> Result<(), String> { + if legacy.len() as u64 > MAX_ACTIONS_BYTES || atn.len() as u64 > MAX_ACTIONS_BYTES { + return Err("Actions migration exceeds file limit".into()); + } + if files.get(LEGACY_ACTIONS_FILE).map(Vec::as_slice) != Some(legacy) { + return Err("legacy Actions input changed before migration".into()); + } + match files.get(ACTIONS_FILE) { + Some(bytes) if published && bytes == atn => {} + None if !published => {} + _ => return Err("Actions ATN changed before migration".into()), + } + let total = files.values().try_fold(0u64, |total, value| total.checked_add(value.len() as u64)).ok_or("store size overflow")?; + let extra = if published { 0 } else { atn.len() as u64 }; + if total.checked_add(extra).is_none_or(|total| total > MAX_STORE_BYTES) { + return Err("preset store is full".into()); + } + Ok(()) +} + /// A store directory on disk (desktop). #[cfg(not(target_arch = "wasm32"))] pub struct DirBackend { @@ -224,6 +272,48 @@ impl PresetBackend for DirBackend { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; photocraft_format::atomic_write(&path, bytes).map_err(|e| format!("{name}: {e}")) } + fn migrate_actions(&self, legacy: &[u8], atn: &[u8], published: bool) -> Result { + use photocraft_format::atomic::{AtomicFs, RealFs}; + if legacy.len() as u64 > MAX_ACTIONS_BYTES || atn.len() as u64 > MAX_ACTIONS_BYTES { + return Err("Actions migration exceeds file limit".into()); + } + if self.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES)? != legacy { + return Err("legacy Actions input changed before migration".into()); + } + let total = self.list()?.iter().try_fold(0u64, |total, (_, size)| total.checked_add(*size)).ok_or("store size overflow")?; + if total.checked_add(if published { 0 } else { atn.len() as u64 }).is_none_or(|total| total > MAX_STORE_BYTES) { + return Err("preset store is full".into()); + } + if published { + if self.read(ACTIONS_FILE, MAX_ACTIONS_BYTES)? != atn { + return Err("Actions ATN changed before legacy retirement".into()); + } + // A previous process may have published ATN but failed its directory flush. + if let Err(error) = RealFs.sync_dir(&self.root) { + return Ok(ActionsMigration::PublishedRetirementPending(error.to_string())); + } + } else { + std::fs::create_dir_all(&self.root).map_err(|error| error.to_string())?; + if let Err(error) = photocraft_format::write_noreplace(&self.path(ACTIONS_FILE)?, atn) { + return if error.published { Ok(ActionsMigration::PublishedRetirementPending(error.to_string())) } else { Err(error.to_string()) }; + } + } + // Recheck the exact source after publication. Never delete or overwrite either legacy name. + let retirement = (|| -> Result<(), String> { + if self.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES)? != legacy { + return Err("legacy Actions input changed after ATN publication".into()); + } + photocraft_format::rename_noreplace(&self.path(LEGACY_ACTIONS_FILE)?, &self.path(MIGRATED_ACTIONS_FILE)?).map_err(|error| error.to_string())?; + if self.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES)? != legacy { + return Err("legacy renamed, but retained bytes changed; all copies preserved for recovery".into()); + } + RealFs.sync_dir(&self.root).map_err(|error| format!("legacy renamed; directory durability unconfirmed: {error}")) + })(); + Ok(match retirement { + Ok(()) => ActionsMigration::Stored, + Err(error) => ActionsMigration::PublishedRetirementPending(error), + }) + } fn remove(&self, name: &str) -> Result<(), String> { match std::fs::remove_file(self.path(name)?) { Ok(()) => Ok(()), @@ -564,7 +654,8 @@ pub struct PresetStore { index: Option, /// [`crate::actions_cmds::ActionState::rev`] last written (or loaded). actions_rev: u64, - /// `actions.json` exists but failed to load: left on disk untouched, so it is never written. + actions_keys: Vec<(String, String)>, + /// An existing library failed to load or migrate; preserve it on disk. actions_skipped: bool, /// The pattern files and index (`patterns.json`). patterns: patterns::PatternFiles, @@ -587,12 +678,42 @@ pub struct Opened { pub order: Vec, /// Files that were skipped (corrupt, oversized, missing tips). pub warnings: Vec, - /// Recorded actions (`actions.json`). Empty when the file is missing or unreadable. - pub actions: Vec, + /// Complete typed Actions load, including physical rows and protected failures. + pub actions: LoadedActions, /// Imported and defined patterns (`patterns.json`), in library order; large ones as previews. pub patterns: Vec, } +/// Missing, complete and unreadable inputs must not collapse into the same empty list. +#[derive(Clone, Debug)] +pub enum LoadedActions { + Missing, + Loaded(crate::actions_cmds::ActionState), + Migrated(crate::actions_cmds::ActionState), + MigrationBlocked(crate::actions_cmds::ActionState), + Failed, +} +impl LoadedActions { + fn writable(&self) -> bool { + matches!(self, Self::Missing | Self::Loaded(_) | Self::Migrated(_)) + } + fn into_state(self) -> Option { + match self { + Self::Loaded(state) | Self::Migrated(state) | Self::MigrationBlocked(state) => Some(state), + _ => None, + } + } +} +impl std::ops::Deref for LoadedActions { + type Target = [crate::actions_cmds::Action]; + fn deref(&self) -> &Self::Target { + match self { + Self::Loaded(state) | Self::Migrated(state) | Self::MigrationBlocked(state) => &state.list, + _ => &[], + } + } +} + /// Load a store (any thread; the desktop app does this in the background at start). pub fn open(backend: Box) -> Opened { let mut warnings = Vec::new(); @@ -664,9 +785,8 @@ pub fn open(backend: Box) -> Opened { } let (tiles, regenerated) = load_tiles(backend.as_ref(), &wanted, &tip_sizes, &info, &mut warnings); - let loaded_actions = load_actions(backend.as_ref(), &mut warnings); - let actions_skipped = loaded_actions.is_none(); - let actions = loaded_actions.unwrap_or_default(); + let actions = load_actions(backend.as_ref(), &mut warnings); + let actions_skipped = !actions.writable(); let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings); let mut store = PresetStore { backend, @@ -676,6 +796,7 @@ pub fn open(backend: Box) -> Opened { tips: HashMap::new(), index: None, actions_rev: 0, + actions_keys: Vec::new(), actions_skipped, patterns: pattern_files, warnings: Vec::new(), @@ -739,27 +860,82 @@ pub fn open(backend: Box) -> Opened { Opened { store, presets, hidden_builtins, order, warnings, actions, patterns } } -/// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. -/// The saved actions; `None` when `actions.json` exists but could not be loaded. -fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option> { - let bytes = match backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES) { - Ok(b) => b, - Err(e) if missing_file(&e) => return Some(Vec::new()), - Err(e) => { - warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}")); - return None; - } +/// Prefer ATN unconditionally. Only absence admits the one-time legacy JSON reader. +fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> LoadedActions { + use crate::actions_cmds::{ActionState, library}; + #[derive(serde::Deserialize)] + struct LegacyActionsFile { + version: u32, + actions: Vec, + } + let fail = |warnings: &mut Vec, name: &str, error: String| { + warnings.push(format!("actions: {name} skipped: {error}")); + LoadedActions::Failed }; - match serde_json::from_slice::(&bytes) { - Ok(file) if file.version == 1 => Some(file.actions), - Ok(file) => { - warnings.push(format!("actions: {ACTIONS_FILE} skipped: unsupported version {}", file.version)); - None + match backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES) { + Ok(bytes) => { + let state = match library::decode(&bytes) { + Ok(state) => state, + Err(error) => return fail(warnings, ACTIONS_FILE, error), + }; + if let Some(origin) = &state.library.legacy { + match backend.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES) { + Ok(legacy) if origin.matches(&legacy) => match backend.migrate_actions(&legacy, &bytes, true) { + Ok(ActionsMigration::PublishedRetirementPending(error)) | Err(error) => { + warnings.push(format!("actions: ATN loaded; legacy retirement pending: {error}")) + } + _ => {} + }, + Ok(_) => warnings.push("actions: ATN loaded; differing legacy JSON retained unchanged".into()), + Err(error) if missing_file(&error) => match backend.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES) { + Ok(backup) if origin.matches(&backup) => {} + Ok(_) => warnings.push("actions: ATN loaded; retained legacy backup does not match its origin".into()), + Err(error) => warnings.push(format!("actions: ATN loaded; legacy recovery copy unavailable: {error}")), + }, + Err(error) => warnings.push(format!("actions: ATN loaded; legacy retirement pending: {error}")), + } + } + LoadedActions::Loaded(state) } - Err(e) => { - warnings.push(format!("actions: {ACTIONS_FILE} skipped: {e}")); - None + Err(error) if missing_file(&error) => { + let legacy = match backend.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES) { + Ok(bytes) => bytes, + Err(error) if missing_file(&error) => return LoadedActions::Missing, + Err(error) => return fail(warnings, LEGACY_ACTIONS_FILE, error), + }; + let file: LegacyActionsFile = match serde_json::from_slice(&legacy) { + Ok(file) => file, + Err(error) => return fail(warnings, LEGACY_ACTIONS_FILE, error.to_string()), + }; + if file.version != 1 { + return fail(warnings, LEGACY_ACTIONS_FILE, format!("unsupported version {}", file.version)); + } + let mut state = ActionState { list: file.actions, ..Default::default() }; + state.library.legacy = match library::LegacyOrigin::new(&legacy) { + Ok(origin) => Some(origin), + Err(error) => return fail(warnings, LEGACY_ACTIONS_FILE, error), + }; + let bytes = match library::encode(&state, &[]) { + Ok(bytes) => bytes, + Err(error) => return fail(warnings, LEGACY_ACTIONS_FILE, error), + }; + match backend.migrate_actions(&legacy, &bytes, false) { + Ok(status) => { + if let ActionsMigration::PublishedRetirementPending(error) = status { + warnings.push(format!("actions: ATN published; legacy retirement pending: {error}")); + } + match library::decode(&bytes) { + Ok(state) => LoadedActions::Migrated(state), + Err(error) => fail(warnings, ACTIONS_FILE, error), + } + } + Err(error) => { + warnings.push(format!("actions: legacy library loaded in memory; migration blocked: {error}")); + LoadedActions::MigrationBlocked(state) + } + } } + Err(error) => fail(warnings, ACTIONS_FILE, error), } } @@ -1091,39 +1267,61 @@ impl PresetStore { Ok(p) } - /// Write the Actions list when `rev` differs from the last load or write. - /// An oversized list is warned once and not retried. An I/O error is warned and retried - /// on the next command. - pub fn sync_actions(&mut self, list: &[crate::actions_cmds::Action], rev: u64) { - if self.actions_rev == rev { + /// Persist the complete library, including physical empty rows and shortcut-only header edits. + pub fn sync_actions(&mut self, state: &crate::actions_cmds::ActionState, keys: &[(String, String)]) { + if self.actions_rev == state.rev && self.actions_keys == keys { return; } if self.actions_skipped { - self.warnings.push(format!("Couldn't save actions: {ACTIONS_FILE} could not be read, so it was left unchanged")); - self.actions_rev = rev; + self.warnings.push("Couldn't save actions: the existing library could not be safely loaded or migrated, so it was left unchanged".into()); + self.actions_rev = state.rev; + self.actions_keys = keys.to_vec(); return; } - let file = crate::actions_cmds::ActionsFile { version: 1, actions: list.to_vec() }; - let bytes = match serde_json::to_vec_pretty(&file) { - Ok(b) => b, - Err(e) => { - self.warnings.push(format!("Couldn't save actions: {e}")); - self.actions_rev = rev; + let bytes = match crate::actions_cmds::library::encode(state, keys) { + Ok(bytes) => bytes, + Err(error) => { + self.warnings.push(format!("Couldn't save actions: {error}")); + self.actions_rev = state.rev; + self.actions_keys = keys.to_vec(); return; } }; - if bytes.len() as u64 > MAX_ACTIONS_BYTES { - self.warnings.push(format!("actions: the action list is too large to save ({} MB limit)", MAX_ACTIONS_BYTES >> 20)); - self.actions_rev = rev; - return; + let total = self.backend.list().and_then(|files| { + files + .into_iter() + .filter(|(name, _)| name != ACTIONS_FILE) + .try_fold(bytes.len() as u64, |total, (_, size)| total.checked_add(size).ok_or_else(|| "store size overflow".to_string())) + }); + match total { + Ok(total) if total <= MAX_STORE_BYTES => {} + Ok(_) => { + self.warnings.push("Couldn't save actions: preset store is full".into()); + return; + } + Err(error) => { + self.warnings.push(format!("Couldn't save actions: {error}")); + return; + } } match self.backend.write(ACTIONS_FILE, &bytes) { - Ok(()) => self.actions_rev = rev, - Err(e) => self.warnings.push(format!("Couldn't save actions: {e}")), + Ok(()) => { + self.actions_rev = state.rev; + self.actions_keys = keys.to_vec(); + } + Err(error) => self.warnings.push(format!("Couldn't save actions: {error}")), } } } +fn action_shortcuts(prefs: &crate::prefs::Preferences) -> Vec<(String, String)> { + prefs + .shortcuts + .iter() + .filter_map(|(key, value)| key.strip_prefix(crate::actions_cmds::PLAY_SHORTCUT_PREFIX).map(|name| (name.to_string(), value.clone()))) + .collect() +} + fn builtin_names() -> &'static [String] { static NAMES: std::sync::OnceLock> = std::sync::OnceLock::new(); NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect()) @@ -1179,21 +1377,35 @@ impl Session { lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX)); } self.preset_store = Some(store); - // In-memory actions (created before the store finished loading) win on name. Disk-only - // names are appended. An empty session takes the disk list and does not rewrite it. - if self.actions.list.is_empty() { - self.actions.list = disk; - self.actions.rev = 1; - if let Some(st) = self.preset_store.as_mut() { - st.actions_rev = 1; + let migrating = matches!(disk, LoadedActions::Migrated(_) | LoadedActions::MigrationBlocked(_)); + if let Some(disk) = disk.into_state() { + let keys: HashMap<_, _> = crate::actions_cmds::library::function_keys(&disk).into_iter().collect(); + let names: HashSet<_> = disk.list.iter().map(|action| action.name.clone()).collect(); + let pristine = self.actions.rev == 0 && self.actions.list.is_empty() && self.actions.sets.is_empty(); + crate::actions_cmds::library::attach(&mut self.actions, disk); + if !migrating { + // An existing ATN owns its function-key headers. Stale preference bindings + // must not silently rewrite an untouched library on attachment. + for name in names { + if self.actions.list.iter().filter(|action| action.name == name).count() == 1 { + self.edit_prefs(|prefs| { + let command = format!("{}{name}", crate::actions_cmds::PLAY_SHORTCUT_PREFIX); + if let Some(key) = keys.get(&name) { + prefs.shortcuts.insert(command, key.clone()); + } else if prefs.shortcuts.get(&command).is_some_and(|key| crate::atn_cmds::parse_function_key(key).is_some()) { + prefs.shortcuts.remove(&command); + } + }); + } + } } - } else { - for action in disk { - if !self.actions.list.iter().any(|have| have.name == action.name) { - self.actions.list.push(action); + if pristine && !migrating { + let keys = action_shortcuts(self.prefs()); + if let Some(store) = self.preset_store.as_mut() { + store.actions_rev = self.actions.rev; + store.actions_keys = keys; } } - self.actions.rev = self.actions.rev.saturating_add(1).max(1); } self.brush_presets_changed(); self.sync_preset_store(); @@ -1211,7 +1423,7 @@ impl Session { /// Write pending brush preset, Actions and pattern changes to the attached store (no-op without one). pub fn sync_preset_store(&mut self) { let rev = self.tools.presets_rev; - let actions_rev = self.actions.rev; + let action_keys = action_shortcuts(self.prefs()); if let Some(st) = self.preset_store.as_mut() { if st.synced_rev != Some(rev) { st.sync(&mut self.tools.presets); @@ -1222,7 +1434,7 @@ impl Session { self.tools.brush_changed(); } } - st.sync_actions(&self.actions.list, actions_rev); + st.sync_actions(&self.actions, &action_keys); st.sync_patterns(&self.patterns); } } diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index 7f0ead6c6ba..6b2df64a0eb 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -1136,3 +1136,43 @@ fn migration_preserves_duplicate_names_but_refuses_duplicate_ids() { assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); } + +#[test] +fn directory_actions_migration_retains_exact_bytes_and_is_idempotent_on_restart() { + let dir = TempDir::new("actions-atn-migration"); + let backend = DirBackend::new(&dir.0); + let legacy = br#" {"version":1,"actions":[{"name":"Native","steps":[["future.command",{"x":1}]]}]} "#; + backend.write(LEGACY_ACTIONS_FILE, legacy).unwrap(); + let opened = open_dir(&dir.0); + assert!(opened.warnings.is_empty(), "{:?}", opened.warnings); + assert_eq!(opened.actions[0].steps, [("future.command".into(), json!({"x": 1}))]); + assert_eq!(backend.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), legacy); + assert!(backend.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); + let atn = backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + assert!(atn.starts_with(&16u32.to_be_bytes())); + let again = open_dir(&dir.0); + assert!(again.warnings.is_empty(), "{:?}", again.warnings); + assert_eq!(backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), atn); + assert_eq!(backend.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), legacy); +} + +#[test] +fn directory_actions_migration_does_not_replace_either_preexisting_destination() { + let dir = TempDir::new("actions-atn-no-clobber"); + let backend = DirBackend::new(&dir.0); + let legacy = br#"{"version":1,"actions":[]}"#; + backend.write(LEGACY_ACTIONS_FILE, legacy).unwrap(); + backend.write(ACTIONS_FILE, b"preexisting damaged ATN").unwrap(); + let failed = open_dir(&dir.0); + assert!(matches!(failed.actions, LoadedActions::Failed)); + assert_eq!(backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), b"preexisting damaged ATN"); + assert_eq!(backend.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), legacy); + backend.remove(ACTIONS_FILE).unwrap(); + backend.write(MIGRATED_ACTIONS_FILE, b"preexisting recovery input").unwrap(); + let pending = open_dir(&dir.0); + assert!(matches!(pending.actions, LoadedActions::Migrated(_))); + assert!(pending.warnings.iter().any(|warning| warning.contains("pending"))); + assert_eq!(backend.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), legacy); + assert_eq!(backend.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), b"preexisting recovery input"); + assert!(backend.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap().starts_with(&16u32.to_be_bytes())); +} diff --git a/crates/engine/tests/it/action_metadata.rs b/crates/engine/tests/it/action_metadata.rs index bdefc57cf8c..d28732a8247 100644 --- a/crates/engine/tests/it/action_metadata.rs +++ b/crates/engine/tests/it/action_metadata.rs @@ -1,11 +1,27 @@ //! Action metadata compatibility, editor staging and independent ATN sidecar output checks. use photocraft_engine::{ Session, - actions_cmds::{Action, ActionsFile}, - preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, + actions_cmds::Action, + preset_store::{self, ACTIONS_FILE, LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES, MIGRATED_ACTIONS_FILE, MemBackend, PresetBackend}, }; use serde_json::json; +fn stored(actions: &[Action]) -> MemBackend { + let mem = MemBackend::default(); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); + session.actions.list = actions.to_vec(); + session.actions.rev += 1; + session.sync_preset_store(); + assert!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap().starts_with(&16u32.to_be_bytes())); + mem +} + +fn assert_definitions(actual: &[Action], expected: &[Action]) { + // The public command shape covers every editable definition field, omitting runtime origins. + assert_eq!(serde_json::to_value(actual).unwrap(), serde_json::to_value(expected).unwrap()); +} + const DESCRIPTION: &str = " First line\nCafé 🌿\0 "; const UNKNOWN_ICON: &str = " future/editor-icon-图标 "; @@ -48,50 +64,49 @@ fn described(name: &str, icon: &str) -> Action { } #[test] -fn old_native_library_defaults_metadata_without_rewriting_on_attach() { +fn legacy_library_migrates_defaults_and_retains_exact_recovery_input() { let legacy = br#"{"version":1,"actions":[{"name":"Legacy","steps":[["future.step",{"raw":[0,255]}]]}]}"#; let mem = MemBackend::default(); - mem.write(ACTIONS_FILE, legacy).unwrap(); + mem.write(LEGACY_ACTIONS_FILE, legacy).unwrap(); let mut session = Session::new(); assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); let action = &session.actions.list[0]; assert!(action.description.is_empty()); assert!(action.icon.is_empty()); - assert_eq!(*action, Action::new("Legacy", vec![("future.step".into(), json!({"raw": [0, 255]}))])); + assert_definitions(std::slice::from_ref(action), &[Action::new("Legacy", vec![("future.step".into(), json!({"raw": [0, 255]}))])]); assert_eq!(serde_json::to_value(action).unwrap(), json!({"name": "Legacy", "steps": [["future.step", {"raw": [0, 255]}]]})); assert!(Action::default().description.is_empty()); assert!(Action::default().icon.is_empty()); session.sync_preset_store(); - assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap().as_slice(), legacy); + assert_eq!(mem.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap().as_slice(), legacy); + assert!(mem.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); + let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + assert!(bytes.starts_with(&16u32.to_be_bytes())); + let reopened = preset_store::open(Box::new(mem)); + assert_definitions(&reopened.actions, &session.actions.list); } #[test] -fn native_json_preserves_descriptions_and_known_or_unknown_icon_identifiers() { +fn native_atn_preserves_descriptions_and_known_or_unknown_icon_identifiers() { for icon in ["ClickAction", UNKNOWN_ICON] { - let original = described("Described", icon); - let file = ActionsFile { version: 1, actions: vec![original.clone()] }; - let bytes = serde_json::to_vec_pretty(&file).unwrap(); - let stored: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(stored["version"], 1); - assert_eq!(stored["actions"][0]["description"], DESCRIPTION); - assert_eq!(stored["actions"][0]["icon"], icon); - let reopened: ActionsFile = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(reopened, file); + let original = described("Editable", icon); + let mem = stored(std::slice::from_ref(&original)); + let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + assert_exported_metadata(&bytes, icon); + let reopened = preset_store::open(Box::new(mem)); + assert_definitions(&reopened.actions, std::slice::from_ref(&original)); assert_eq!(reopened.actions[0].description, DESCRIPTION); assert_eq!(reopened.actions[0].icon, icon); - assert_eq!(original.description, DESCRIPTION); - assert_eq!(original.icon, icon); } } #[test] fn cloned_actions_keep_metadata_through_rename_and_real_preset_reload() { - let initial = ActionsFile { version: 1, actions: vec![described("Known", "ClickAction"), described("Unknown", UNKNOWN_ICON)] }; - let mem = MemBackend::default(); - mem.write(ACTIONS_FILE, &serde_json::to_vec_pretty(&initial).unwrap()).unwrap(); + let initial = vec![described("Known", "ClickAction"), described("Unknown", UNKNOWN_ICON)]; + let mem = stored(&initial); let mut session = Session::new(); assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); - assert_eq!(session.actions.list, initial.actions); + assert_definitions(&session.actions.list, &initial); // Exercise the model clone here; native duplication has its own regression tests. let mut copy = session.actions.list[1].clone(); copy.name = "Copy".into(); @@ -104,21 +119,20 @@ fn cloned_actions_keep_metadata_through_rename_and_real_preset_reload() { for (action, icon) in session.actions.list.iter().zip(["ClickAction", UNKNOWN_ICON, UNKNOWN_ICON]) { assert_eq!(action.description, DESCRIPTION); assert_eq!(action.icon, icon); - assert_eq!(action.steps, initial.actions[0].steps); - assert_eq!(action.disabled, initial.actions[0].disabled); - assert_eq!(action.dialogs, initial.actions[0].dialogs); - assert_eq!(action.set, initial.actions[0].set); - assert_eq!(action.color, initial.actions[0].color); + assert_eq!(action.steps, initial[0].steps); + assert_eq!(action.disabled, initial[0].disabled); + assert_eq!(action.dialogs, initial[0].dialogs); + assert_eq!(action.set, initial[0].set); + assert_eq!(action.color, initial[0].color); } session.sync_preset_store(); let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); - let saved: ActionsFile = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(saved.version, 1); - assert_eq!(saved.actions, session.actions.list); + let saved = preset_store::open(Box::new(mem.clone())); + assert_definitions(&saved.actions, &session.actions.list); assert_eq!(saved.actions.iter().map(|action| action.name.as_str()).collect::>(), ["Renamed known", "Renamed unknown", "Renamed copy"]); let mut reopened = Session::new(); assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); - assert_eq!(reopened.actions.list, saved.actions); + assert_definitions(&reopened.actions.list, &saved.actions); assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); } @@ -127,9 +141,7 @@ fn cloned_actions_keep_metadata_through_rename_and_real_preset_reload() { fn editor_metadata_stays_draft_until_save_and_discard_keeps_saved_values() { for icon in ["ClickAction", UNKNOWN_ICON] { let mut original = Action::new("Editable", Vec::new()); - let initial = ActionsFile { version: 1, actions: vec![original.clone()] }; - let mem = MemBackend::default(); - mem.write(ACTIONS_FILE, &serde_json::to_vec_pretty(&initial).unwrap()).unwrap(); + let mem = stored(std::slice::from_ref(&original)); let mut session = Session::new(); assert!(session.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); let committed = session.actions.list.clone(); @@ -156,17 +168,17 @@ fn editor_metadata_stays_draft_until_save_and_discard_keeps_saved_values() { assert_eq!(saved["dirty"], false); original.description = DESCRIPTION.into(); original.icon = icon.into(); - assert_eq!(session.actions.list, vec![original.clone()]); + assert_definitions(&session.actions.list, std::slice::from_ref(&original)); let classic = session.execute("actions.get", json!({"action": "Editable"})).unwrap(); assert_eq!(classic["description"], DESCRIPTION); assert_eq!(classic["icon"], icon); session.sync_preset_store(); let saved_bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); - let native: ActionsFile = serde_json::from_slice(&saved_bytes).unwrap(); - assert_eq!(native.actions, vec![original.clone()]); + let native = preset_store::open(Box::new(mem.clone())); + assert_definitions(&native.actions, std::slice::from_ref(&original)); let mut reopened = Session::new(); assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); - assert_eq!(reopened.actions.list, native.actions); + assert_definitions(&reopened.actions.list, &native.actions); // Metadata now exports through the real sidecar, including an unknown glyph identifier. let committed = session.actions.list.clone(); let mut plain = original.clone(); @@ -185,13 +197,16 @@ fn editor_metadata_stays_draft_until_save_and_discard_keeps_saved_values() { } let mut unsupported = original.clone(); unsupported.steps.push(("future.step".into(), json!({"raw": [0,255]}))); - assert!(photocraft_engine::atn_cmds::export("Metadata set", &[&unsupported], &[], false).is_err()); + let (kept, skipped) = photocraft_engine::atn_cmds::export("Metadata set", &[&unsupported], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(photocraft_engine::atn_cmds::import(&kept).unwrap().actions[0].steps, unsupported.steps); + assert_eq!(photocraft_psd::atn::parse_sets(&kept).unwrap()[0].actions[0].items[0].event.id(), "photocraftCommand"); let (bytes, skipped) = photocraft_engine::atn_cmds::export("Metadata set", &[&unsupported], &[], true).unwrap(); assert_eq!(skipped.len(), 1, "skipUnsupported affects steps, never metadata"); assert_exported_metadata(&bytes, icon); session.execute("actions.editor.update", json!({"draft": draft, "description": "Discard me", "icon": "UncommittedIcon"})).unwrap(); session.execute("actions.editor.discard", json!({"draft": draft})).unwrap(); - assert_eq!(session.actions.list, vec![original]); + assert_definitions(&session.actions.list, std::slice::from_ref(&original)); assert_eq!(mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), saved_bytes); } } diff --git a/crates/engine/tests/it/atn_action_colors.rs b/crates/engine/tests/it/atn_action_colors.rs index 520f5e0c50b..d44eb1cf5b0 100644 --- a/crates/engine/tests/it/atn_action_colors.rs +++ b/crates/engine/tests/it/atn_action_colors.rs @@ -1,7 +1,7 @@ //! Action-header colours survive the public ATN and native action-library paths. use photocraft_engine::{ Session, - actions_cmds::{Action, ActionsFile}, + actions_cmds::Action, atn_cmds, preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, }; @@ -131,17 +131,19 @@ fn unknown_header_colours_survive_native_persistence_and_unrelated_edits() { assert_eq!(s.actions.list[0].color, opaque); s.sync_preset_store(); let bytes = mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); - let native: ActionsFile = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(native.actions, s.actions.list); + let native = atn::parse_sets(&bytes).unwrap(); + assert_eq!(native[0].actions[0].color, index); + assert_eq!(native[0].actions[0].function_key, 6); + same_step_wire(&native[0].actions[0].items[0], &recorded(index).actions[0].items[0]); let mut reopened = Session::new(); assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem.clone()))).is_empty()); - assert_eq!(reopened.actions.list, native.actions); + assert_eq!(serde_json::to_value(&reopened.actions.list).unwrap(), serde_json::to_value(&s.actions.list).unwrap()); reopened.execute("actions.rename", json!({"action": NAME, "name": "Renamed"})).unwrap(); assert_eq!(reopened.actions.list[0].color, opaque); reopened.sync_preset_store(); let mut again = Session::new(); assert!(again.attach_preset_store(preset_store::open(Box::new(mem))).is_empty()); - assert_eq!(again.actions.list, reopened.actions.list); + assert_eq!(serde_json::to_value(&again.actions.list).unwrap(), serde_json::to_value(&reopened.actions.list).unwrap()); let output = saved(&mut again); assert_eq!(output.actions[0].color, index); assert_eq!(output.actions[0].name.to_string_lossy(), "Renamed"); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 696b52cb723..d09bc80813f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -32,6 +32,7 @@ mod convert; mod finite; pub mod manifest; mod migrate; +mod noreplace; pub mod read; mod store; pub mod zip; @@ -47,6 +48,7 @@ pub use autosave::RecoveryStore; pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, recover}; pub use convert::MAX_GROUP_DEPTH; pub use manifest::{FORMAT_VERSION, Manifest}; +pub use noreplace::{PublishNewError, rename_noreplace, write_noreplace}; pub use read::read_file; pub use store::{ByteSource, DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats, read_entry}; diff --git a/crates/format/src/noreplace.rs b/crates/format/src/noreplace.rs new file mode 100644 index 00000000000..55acde88517 --- /dev/null +++ b/crates/format/src/noreplace.rs @@ -0,0 +1,160 @@ +//! No-clobber retirement of a legacy file beside its replacement. +//! +//! This is one rename, not a transaction with the preceding ATN publication. A caller must +//! preserve/report an incomplete migration if the operation fails. In particular, never +//! replace this with an existence check followed by an overwriting rename. + +use std::{io, path::Path}; + +/// Publication failure distinguishes an unchanged destination from an unconfirmed directory flush. +#[derive(Debug)] +pub struct PublishNewError { + pub published: bool, + pub error: io::Error, +} + +impl std::fmt::Display for PublishNewError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.published { write!(f, "file published; directory durability unconfirmed: {}", self.error) } else { std::fmt::Display::fmt(&self.error, f) } + } +} +impl std::error::Error for PublishNewError {} + +/// Publish a complete, flushed file only if its destination is still absent. +/// Temporary files are created exclusively beside the destination; only our own temp is removed. +pub fn write_noreplace(path: &Path, bytes: &[u8]) -> Result<(), PublishNewError> { + use crate::atomic::{AtomicFs, RealFs}; + use std::{ + io::Write, + sync::atomic::{AtomicU64, Ordering}, + }; + static NEXT: AtomicU64 = AtomicU64::new(0); + let before = |error| PublishNewError { published: false, error }; + let dir = path.parent().ok_or_else(|| before(io::Error::new(io::ErrorKind::InvalidInput, "missing publication directory")))?; + let mut owned = None; + for _ in 0..32 { + let temp = dir.join(format!(".pc-new-{}-{}.tmp", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&temp) { + Ok(file) => { + owned = Some((temp, file)); + break; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(before(error)), + } + } + let (temp, mut file) = owned.ok_or_else(|| before(io::Error::new(io::ErrorKind::AlreadyExists, "no unused publication temp name")))?; + let written = file.write_all(bytes).and_then(|_| crate::atomic::sync_file(&file)); + drop(file); + let published = written.and_then(|_| rename_noreplace(&temp, path)); + if let Err(error) = published { + let _ = std::fs::remove_file(&temp); + return Err(before(error)); + } + RealFs.sync_dir(dir).map_err(|error| PublishNewError { published: true, error }) +} + +/// Rename within one directory, refusing an existing destination atomically. +/// No fallback overwrites a backup or copies across volumes. Unsupported kernels/filesystems +/// return their error so the caller can keep both the ATN and original legacy input. +/// The caller must flush the directory before reporting crash-durable migration completion; +/// the existing `atomic::AtomicFs::sync_dir` provides that separately fallible operation. +pub fn rename_noreplace(from: &Path, to: &Path) -> io::Result<()> { + if from == to || from.parent() != to.parent() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "no-replace retirement requires distinct names in one directory")); + } + platform(from, to) +} + +#[cfg(windows)] +fn platform(from: &Path, to: &Path) -> io::Result<()> { + let from = from.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "legacy file path is not valid Unicode"))?; + let to = to.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "backup path is not valid Unicode"))?; + // MoveFile (unlike MoveFileEx with REPLACE_EXISTING) fails if the destination exists. + // The same-directory restriction above also excludes its cross-volume copy behavior. + winsafe::MoveFile(from, to).map_err(|error| io::Error::from_raw_os_error(error.raw() as i32)) +} + +#[cfg(all(unix, any(target_os = "linux", target_os = "android", target_os = "redox", target_vendor = "apple")))] +fn platform(from: &Path, to: &Path) -> io::Result<()> { + // rustix maps NOREPLACE to Linux renameat2 or Apple's RENAME_EXCL, without unsafe FFI here. + rustix::fs::renameat_with(rustix::fs::CWD, from, rustix::fs::CWD, to, rustix::fs::RenameFlags::NOREPLACE).map_err(io::Error::from) +} + +#[cfg(not(any(windows, all(unix, any(target_os = "linux", target_os = "android", target_os = "redox", target_vendor = "apple")))))] +fn platform(_from: &Path, _to: &Path) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "this platform has no supported no-replace retirement primitive")) +} + +#[cfg(all(test, any(windows, all(unix, any(target_os = "linux", target_os = "android", target_os = "redox", target_vendor = "apple")))))] +mod tests { + use super::*; + use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + struct Dir(PathBuf); + impl Dir { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!("pc-noreplace-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + // Never clean a preexisting directory to make the fixture pass. + std::fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn retirement_moves_the_original_bytes_without_overwriting_a_backup() { + let dir = Dir::new(); + let from = dir.0.join("legacy"); + let to = dir.0.join("retained"); + std::fs::write(&from, b"exact original bytes").unwrap(); + std::fs::write(&to, b"existing backup").unwrap(); + assert_eq!(rename_noreplace(&from, &to).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&from).unwrap(), b"exact original bytes"); + assert_eq!(std::fs::read(&to).unwrap(), b"existing backup"); + std::fs::remove_file(&to).unwrap(); + rename_noreplace(&from, &to).unwrap(); + assert!(!from.exists()); + assert_eq!(std::fs::read(&to).unwrap(), b"exact original bytes"); + } + + #[test] + fn retirement_refuses_same_name_and_missing_source_without_changing_existing_data() { + let dir = Dir::new(); + let from = dir.0.join("legacy"); + let to = dir.0.join("retained"); + std::fs::write(&to, b"existing backup").unwrap(); + assert!(rename_noreplace(&from, &to).is_err()); + assert_eq!(std::fs::read(&to).unwrap(), b"existing backup"); + assert_eq!(rename_noreplace(&to, &to).unwrap_err().kind(), io::ErrorKind::InvalidInput); + assert_eq!(std::fs::read(&to).unwrap(), b"existing backup"); + } + + #[test] + fn publication_keeps_an_existing_destination_and_leaves_no_owned_temp() { + let dir = Dir::new(); + let path = dir.0.join("actions.atn"); + std::fs::write(&path, b"existing ATN").unwrap(); + let error = write_noreplace(&path, b"replacement").unwrap_err(); + assert!(!error.published); + assert_eq!(std::fs::read(&path).unwrap(), b"existing ATN"); + assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 1); + } + + #[test] + fn publication_creates_complete_bytes_then_removes_its_temp_name() { + let dir = Dir::new(); + let path = dir.0.join("actions.atn"); + write_noreplace(&path, b"complete ATN").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"complete ATN"); + assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 1); + } +} diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index dd3575ed159..e49b0df0fcf 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -44,7 +44,7 @@ use crate::io::{Reader, WriteExt, decode_legacy_name}; mod legacy; mod sets; -pub use sets::{MAX_SETS, MAX_TOTAL_ACTIONS, MAX_TOTAL_ITEMS, parse_sets, parse_sets_with_encoding, write_sets}; +pub use sets::{MAX_SETS, MAX_TOTAL_ACTIONS, MAX_TOTAL_ITEMS, parse_sets, parse_sets_with_encoding, parse_sets_with_spans, write_sets}; /// The version written by this module (Photoshop 6 and every later release). pub const VERSION: u32 = 16; @@ -53,6 +53,51 @@ pub const MAX_ACTIONS: usize = 10_000; /// Most steps read from one action. pub const MAX_ITEMS: usize = 100_000; +/// A complete version16 archive and byte provenance into its original input. +/// Ranges address the caller's input, including original names/flags/descriptor encodings. +/// No source bytes are copied or rewritten by this parser. +#[derive(Debug, Clone, PartialEq)] +pub struct SpannedAtnArchive { + pub sets: Vec, + pub spans: Vec, +} + +/// One physical set, including its original header fields and action occurrences. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AtnSetSpan { + pub range: std::ops::Range, + pub header: std::ops::Range, + pub name: std::ops::Range, + pub expanded: std::ops::Range, + pub count: std::ops::Range, + pub actions: Vec, +} + +/// One physical action. Header ranges let a library edit one field without normalizing others. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AtnActionSpan { + pub range: std::ops::Range, + pub header: std::ops::Range, + pub function_key: std::ops::Range, + pub shift: std::ops::Range, + pub command: std::ops::Range, + pub color: std::ops::Range, + pub name: std::ops::Range, + pub expanded: std::ops::Range, + pub count: std::ops::Range, + pub items: Vec, +} + +/// One original event, even when it maps to several native command steps. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AtnItemSpan { + pub range: std::ops::Range, + /// Expanded, enabled, dialog, dialog-options bytes, in that order. + pub flags: std::ops::Range, + /// Descriptor body after its -1 flag; absent for a descriptor-free event. + pub descriptor: Option>, +} + /// A parsed action set. #[derive(Debug, Clone, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -222,7 +267,8 @@ fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { } /// Read one step with the descriptor dialect selected by the file version. -fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { +fn read_item(r: &mut Reader<'_>, legacy: bool, mut span: Option<&mut AtnItemSpan>) -> Result { + let start = r.pos(); let expanded = r.u8()? != 0; let enabled = r.u8()? != 0; let with_dialog = r.u8()? != 0; @@ -235,21 +281,49 @@ fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { let dictionary = read_ascii(r)?; let descriptor = match r.i32()? { 0 => None, - -1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }), + -1 => { + let start = r.pos(); + let descriptor = if legacy { legacy::read(r)? } else { Descriptor::read(r)? }; + if let Some(span) = span.as_deref_mut() { + span.descriptor = Some(start..r.pos()); + } + Some(descriptor) + } n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), }; + if let Some(span) = span { + span.range = start..r.pos(); + span.flags = start..start.saturating_add(4); + } Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) } /// Read an action and its steps using the file's descriptor dialect. -fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>) -> Result { +fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>, mut span: Option<&mut AtnActionSpan>) -> Result { + let start = r.pos(); let function_key = r.u16()?; + let shift_start = r.pos(); let shift = r.u8()? != 0; + let command_start = r.pos(); let command = r.u8()? != 0; + let color_start = r.pos(); let color = r.u16()?; + let name_start = r.pos(); let name = UnicodeString::read(r)?; + let expanded_start = r.pos(); let expanded = r.u8()? != 0; + let count_start = r.pos(); let n = r.u32()?; + if let Some(span) = span.as_deref_mut() { + span.header = start..r.pos(); + span.function_key = start..shift_start; + span.shift = shift_start..command_start; + span.command = command_start..color_start; + span.color = color_start..name_start; + span.name = name_start..expanded_start; + span.expanded = expanded_start..count_start; + span.count = count_start..r.pos(); + } // Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag. r.check_count(u64::from(n), 20)?; if n as usize > MAX_ITEMS { @@ -260,7 +334,14 @@ fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budge } let mut items = Vec::with_capacity((n as usize).min(1024)); for _ in 0..n { - items.push(read_item(r, legacy)?); + let mut item_span = span.as_ref().map(|_| AtnItemSpan::default()); + items.push(read_item(r, legacy, item_span.as_mut())?); + if let (Some(span), Some(item)) = (span.as_deref_mut(), item_span) { + span.items.push(item); + } + } + if let Some(span) = span { + span.range = start..r.pos(); } Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) } @@ -302,10 +383,23 @@ fn read_version(r: &mut Reader<'_>) -> Result { Ok(version) } -fn read_set(r: &mut Reader<'_>, legacy: bool, mut budget: Option<&mut sets::Budget>) -> Result { +fn read_set(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>) -> Result { + read_set_with_spans(r, legacy, budget, None) +} + +fn read_set_with_spans(r: &mut Reader<'_>, legacy: bool, mut budget: Option<&mut sets::Budget>, mut span: Option<&mut AtnSetSpan>) -> Result { + let start = r.pos(); let name = UnicodeString::read(r)?; + let expanded_start = r.pos(); let expanded = r.u8()? != 0; + let count_start = r.pos(); let n = r.u32()?; + if let Some(span) = span.as_deref_mut() { + span.header = start..r.pos(); + span.name = start..expanded_start; + span.expanded = expanded_start..count_start; + span.count = count_start..r.pos(); + } // Each action needs at least its key, modifiers, colour, an empty name, the flag and a count. r.check_count(u64::from(n), 15)?; if n as usize > MAX_ACTIONS { @@ -316,7 +410,14 @@ fn read_set(r: &mut Reader<'_>, legacy: bool, mut budget: Option<&mut sets::Budg } let mut actions = Vec::with_capacity((n as usize).min(256)); for _ in 0..n { - actions.push(read_action(r, legacy, budget.as_deref_mut())?); + let mut action_span = span.as_ref().map(|_| AtnActionSpan::default()); + actions.push(read_action(r, legacy, budget.as_deref_mut(), action_span.as_mut())?); + if let (Some(span), Some(action)) = (span.as_deref_mut(), action_span) { + span.actions.push(action); + } + } + if let Some(span) = span { + span.range = start..r.pos(); } Ok(AtnFile { name, expanded, actions, warnings: Vec::new(), encoding: None }) } @@ -896,4 +997,86 @@ mod tests { let _ = parse(&bytes); } } + + /// Independent wire input: duplicate raw UTF16 names, noncanonical true bytes, and a + /// descriptor boolean whose semantic value cannot preserve its original byte by itself. + fn spanned_wire_archive() -> Vec { + fn ascii(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); + } + let mut out = 16u32.to_be_bytes().to_vec(); + let raw_name = [0, 0, 0, 2, 0xd8, 0, 0, 0]; // unpaired high surrogate, then NUL + for _ in 0..2 { + out.extend_from_slice(&raw_name); + out.push(2); // expanded=true, retaining its original byte matters + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&[0, 3, 2, 0, 0, 12]); // F3, Shift byte2, no Command, colour12 + out.extend_from_slice(&raw_name); + out.push(2); + out.extend_from_slice(&2u32.to_be_bytes()); + out.extend_from_slice(&[2, 2, 0, 3]); + out.extend_from_slice(b"longFltn"); + ascii(&mut out, b"Flatten"); + out.extend_from_slice(&0i32.to_be_bytes()); + out.extend_from_slice(&[0, 1, 2, 2]); + out.extend_from_slice(b"TEXT"); + ascii(&mut out, b"futureAction"); + ascii(&mut out, b"Future"); + out.extend_from_slice(&(-1i32).to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); // empty descriptor Unicode name + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(b"null"); + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(b"flagbool"); + out.push(2); + } + out + } + + #[test] + fn archive_spans_retain_physical_occurrences_and_original_wire_fields() { + let bytes = spanned_wire_archive(); + let archive = parse_sets_with_spans(&bytes).unwrap(); + assert_eq!(archive.sets.len(), 2); + assert_eq!(archive.spans.len(), 2); + assert_eq!(archive.spans[0].range.start, 4); + assert_eq!(archive.spans[0].range.end, archive.spans[1].range.start); + assert_eq!(archive.spans[1].range.end, bytes.len()); + let mut untouched = bytes[..4].to_vec(); + for (file, set) in archive.sets.iter().zip(&archive.spans) { + assert_eq!(file.name.0, [0xd800, 0]); + assert_eq!(bytes[set.expanded.clone()], [2]); + assert_eq!(bytes[set.count.clone()], 1u32.to_be_bytes()); + assert_eq!(set.header.end, set.actions[0].range.start); + let action = &set.actions[0]; + assert_eq!(bytes[action.function_key.clone()], [0, 3]); + assert_eq!(bytes[action.shift.clone()], [2]); + assert_eq!(bytes[action.command.clone()], [0]); + assert_eq!(bytes[action.color.clone()], [0, 12]); + assert_eq!(bytes[action.expanded.clone()], [2]); + assert_eq!(bytes[action.count.clone()], 2u32.to_be_bytes()); + assert_eq!(action.header.end, action.items[0].range.start); + assert_eq!(bytes[action.items[0].flags.clone()], [2, 2, 0, 3]); + assert_eq!(bytes[action.items[1].flags.clone()], [0, 1, 2, 2]); + assert_eq!(action.items[0].range.end, action.items[1].range.start); + assert_eq!(action.items[1].range.end, action.range.end); + assert_eq!(action.range.end, set.range.end); + assert_eq!(bytes[action.items[1].range.clone()].last(), Some(&2)); + untouched.extend_from_slice(&bytes[set.range.clone()]); + } + assert_eq!(untouched, bytes, "no semantic writer generates the expected raw bytes"); + assert_eq!(parse_sets(&bytes).unwrap(), archive.sets, "ordinary parsing keeps its semantic API"); + assert_ne!(write_sets(&archive.sets).unwrap(), bytes, "typed boolean rewriting is not a raw library round trip"); + } + + #[test] + fn archive_spans_reject_partial_later_records_and_legacy_layout() { + let mut bytes = spanned_wire_archive(); + bytes.pop(); // the second set's descriptor value is incomplete + assert!(parse_sets_with_spans(&bytes).is_err(), "no partial archive is admitted"); + assert!(parse_sets_with_spans(&16u32.to_be_bytes()).is_err(), "a version prefix is not an invented empty ATN"); + assert!(matches!(parse_sets_with_spans(&12u32.to_be_bytes()), Err(PsdError::Unsupported(_)))); + } } diff --git a/crates/psd/src/atn/sets.rs b/crates/psd/src/atn/sets.rs index 53bb93b8d76..7b5c54ff206 100644 --- a/crates/psd/src/atn/sets.rs +++ b/crates/psd/src/atn/sets.rs @@ -1,5 +1,7 @@ //! Whole-file version-16 set streams. The original single-set/legacy API remains compatible. -use super::{AtnFile, MAX_ACTIONS, MAX_ITEMS, VERSION, codepage, name_encoding_sets, read_set, read_version, write_set}; +use super::{ + AtnFile, AtnSetSpan, MAX_ACTIONS, MAX_ITEMS, SpannedAtnArchive, VERSION, codepage, name_encoding_sets, read_set_with_spans, read_version, write_set, +}; use crate::{ error::{PsdError, Result}, io::{Reader, WriteExt}, @@ -46,18 +48,38 @@ pub fn parse_sets(data: &[u8]) -> Result> { /// [`parse_sets`] with one legacy name encoding for the complete file. Automatic detection also /// uses the complete stream; its diagnostic is attached once to the first set. pub fn parse_sets_with_encoding(data: &[u8], encoding: Option<&str>) -> Result> { + parse_archive(data, encoding, false).map(|archive| archive.sets) +} + +/// Read a strict complete version16 archive, retaining physical record/field ranges. +/// Legacy version12 conversion is deliberately excluded: its descriptor dialect cannot be +/// spliced unchanged into a version16 library. Existing import APIs still accept it. +/// A header without any real set remains invalid; an empty logical library needs a valid carrier. +pub fn parse_sets_with_spans(data: &[u8]) -> Result { + parse_archive(data, None, true) +} + +fn parse_archive(data: &[u8], encoding: Option<&str>, capture: bool) -> Result { let forced = encoding.map(codepage::resolve_label).transpose()?; let mut reader = Reader::new(data); let version = read_version(&mut reader)?; let legacy = version == 12; + if capture && legacy { + return Err(PsdError::Unsupported("raw Actions library provenance requires version16".into())); + } let mut files = Vec::new(); + let mut spans = Vec::new(); let mut budget = Budget::default(); loop { if files.len() >= MAX_SETS { return Err(PsdError::LimitExceeded("too many sets in actions archive")); } let before = reader.remaining(); - let mut file = read_set(&mut reader, legacy, Some(&mut budget))?; + let mut span = capture.then(AtnSetSpan::default); + let mut file = read_set_with_spans(&mut reader, legacy, Some(&mut budget), span.as_mut())?; + if let Some(span) = span { + spans.push(span); + } if legacy && !reader.is_empty() { file.warnings.push(format!("{} trailing bytes ignored", reader.remaining())); } @@ -77,7 +99,7 @@ pub fn parse_sets_with_encoding(data: &[u8], encoding: Option<&str>) -> Result-linux-x86_64.AppImage photocraft-cli The `--cli` selector is removed; all remaining arguments, stdin, stdout, stderr and the exit status are passed through. CLI launches skip desktop integration. The symlink form uses the AppImage runtime's `ARGV0`; the normal AppImage name launches the GUI. -`actions.json` holds the steps of a recorded action: `[["", {…}], …]`, `[{"command": "", "params": {…}}, …]` or bare ids, as a list or wrapped in `{"actions": …}` or `{"steps": …}`; a droplet file works too. It can also be an Actions set (the Actions panel's `actions.json`, `{"actions": [{"name": …, "steps": […]}, …]}`): `--action ` picks the action to play (optional when the set holds one), and the whole set is loaded for each file, so a step that plays another action of the set works. `batch` refuses an `--out` folder that is the `--in` folder, since the results would replace the originals, unless `--in-place` is given. `run` prints one JSON line per command result. +`actions.atn` holds the Actions library. `--action ` selects an action +(optional for a one-action library), with the complete library available for nested +calls. A PhotoCraft droplet is also accepted. Legacy JSON action-library files are +rejected; command JSON for `run`, the control protocol and MCP is unchanged. +`batch` refuses an `--out` folder that is the `--in` folder unless `--in-place` is +given. `run` prints one JSON line per command result. ## Native format (.pcraft) From 5a1a06a4dfa548588871f5921b76674d9d78c3ac Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 17:52:35 -0800 Subject: [PATCH 082/384] Correct Actions row helper visibility and document archive spans --- crates/engine/src/cmd/atn_cmds/sidecar.rs | 2 +- crates/psd/src/atn.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs index e450c115139..b73f9b36c64 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -235,7 +235,7 @@ pub(super) fn empty_source(state: &ActionState, slot: usize) -> Option>) { +pub(crate) fn install_row(state: &mut ActionState, name: &str, source: Option>) { let slot = state.sets.len(); state.library.rows.resize(slot, None); state.library.rows.push(None); diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index e49b0df0fcf..8115650ba25 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -58,39 +58,58 @@ pub const MAX_ITEMS: usize = 100_000; /// No source bytes are copied or rewritten by this parser. #[derive(Debug, Clone, PartialEq)] pub struct SpannedAtnArchive { + /// Parsed sets in physical file order. pub sets: Vec, + /// Byte spans for the sets, in the same order. pub spans: Vec, } /// One physical set, including its original header fields and action occurrences. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AtnSetSpan { + /// Half-open input range of the complete set, including its actions. pub range: std::ops::Range, + /// Set header through the action count, before the first action. pub header: std::ops::Range, + /// Length-prefixed Unicode set name bytes. pub name: std::ops::Range, + /// Original expanded-flag byte. pub expanded: std::ops::Range, + /// Original four-byte action count. pub count: std::ops::Range, + /// Action spans in physical file order. pub actions: Vec, } /// One physical action. Header ranges let a library edit one field without normalizing others. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AtnActionSpan { + /// Half-open input range of the complete action, including its steps. pub range: std::ops::Range, + /// Action header through the step count, before the first step. pub header: std::ops::Range, + /// Original function-key code bytes. pub function_key: std::ops::Range, + /// Original Shift-modifier flag byte. pub shift: std::ops::Range, + /// Original command-modifier flag byte. pub command: std::ops::Range, + /// Original color-label code bytes. pub color: std::ops::Range, + /// Length-prefixed Unicode action name bytes. pub name: std::ops::Range, + /// Original expanded-flag byte. pub expanded: std::ops::Range, + /// Original four-byte step count. pub count: std::ops::Range, + /// Step spans in physical file order. pub items: Vec, } /// One original event, even when it maps to several native command steps. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AtnItemSpan { + /// Half-open input range of the complete original step. pub range: std::ops::Range, /// Expanded, enabled, dialog, dialog-options bytes, in that order. pub flags: std::ops::Range, From 771e0ebae10ea6dbe152bd595c08b55ff6571602 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 18:23:43 -0800 Subject: [PATCH 083/384] Test grouped ATN saves without flattening the library --- crates/engine/src/cmd/atn_cmds/tests.rs | 29 ++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index e9a52fb4301..a389e3e9141 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -426,7 +426,16 @@ fn save_round_trips_through_photoshop_events() { saved_native(&mut s, "Ours"); let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap(); assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "unsupported.forTest"}])); - assert_eq!(r["set"], "Ours"); + assert_eq!(r["set"], UNGROUPED_SET); + assert_eq!(r["sets"], json!([UNGROUPED_SET])); + assert_eq!(r["actions"], json!(["Ours"])); + let bytes = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let files = atn::parse_sets(&bytes).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name.to_string_lossy(), ""); + assert_eq!(files[0].actions.len(), 1); + assert_eq!(files[0].actions[0].name.to_string_lossy(), "Ours"); + assert!(files[0].actions[0].items.is_empty(), "only the explicitly skipped step is omitted"); } /// `image.adjustments.selectiveColor` takes a range as an array (`"reds": [c, m, y, k]`) or as @@ -1455,6 +1464,11 @@ fn step_commands_and_bad_params_fail_gracefully() { fn save_writes_the_file_and_the_set_of_the_selected_action() { let mut s = Session::new(); s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap(); + // A default save of one named group retains the legacy output name. + let r = s.execute("actions.save", json!({})).unwrap(); + assert_eq!(r["set"], DEFAULT_SET); + let bytes = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse_sets(&bytes).unwrap()[0].name.to_string_lossy(), DEFAULT_SET); s.actions.list.push(Action::new("Loose", vec![("select.all".into(), json!({}))])); let dir = std::env::temp_dir().join(format!("pc-atn-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -1464,9 +1478,18 @@ fn save_writes_the_file_and_the_set_of_the_selected_action() { let file = atn::parse(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(file.actions.len(), 1); assert_eq!(file.actions[0].name.to_string_lossy(), "Finish"); - // Without a set, everything goes out under the default name. + // Saving all groups preserves the named set and the ungrouped category separately. let r = s.execute("actions.save", json!({})).unwrap(); - assert_eq!((r["set"].as_str(), r["actions"].as_array().map(Vec::len)), (Some(DEFAULT_SET), Some(3))); + assert_eq!((r["set"].as_str(), r["actions"].as_array().map(Vec::len)), (Some("Test Set"), Some(3))); + assert_eq!(r["sets"], json!(["Test Set", UNGROUPED_SET])); + assert_eq!(r["actions"], json!(["Soften", "Finish", "Loose"])); + let bytes = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let files = atn::parse_sets(&bytes).unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name.to_string_lossy(), "Test Set"); + assert_eq!(files[1].name.to_string_lossy(), ""); + assert_eq!(files[0].actions.iter().map(|a| a.name.to_string_lossy()).collect::>(), ["Soften", "Finish"]); + assert_eq!(files[1].actions.iter().map(|a| a.name.to_string_lossy()).collect::>(), ["Loose"]); std::fs::remove_dir_all(&dir).ok(); } From 84ff13327d0b7ccae24d8846a5323add23f2e920 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 18:56:13 -0800 Subject: [PATCH 084/384] Share recording projection between Classic and editor drafts --- crates/engine/src/cmd/actions_cmds.rs | 19 ++- crates/engine/src/cmd/actions_cmds/editor.rs | 4 +- crates/engine/src/cmd/actions_cmds/tests.rs | 118 +++++++++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 9fd4a7ca07b..6094fb2fa81 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -570,18 +570,27 @@ pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { if idx >= s.actions.list.len() { return Err(bad("actions.stop", "the action being recorded no longer exists")); } - let steps = fold_recorded_nudges(pending_steps(s, idx).cloned()); + let steps = project_recorded_steps(pending_steps(s, idx).cloned()); s.actions.recording = Some((s.journal.len(), idx)); if !steps.is_empty() { - let action = &mut s.actions.list[idx]; - let first = action.steps.len(); - action.dialogs.extend(steps.iter().enumerate().filter(|(_, (id, _))| RECORDED_WITH_DIALOG.contains(&id.as_str())).map(|(i, _)| first + i)); - action.steps.extend(steps); + append_recorded_steps(&mut s.actions.list[idx], steps); s.actions.touch(); } Ok(()) } +/// The logical capture projection shared by Classic and the unpublished editor. +fn project_recorded_steps(steps: impl IntoIterator) -> Vec<(String, Value)> { + fold_recorded_nudges(steps.into_iter().filter(|(id, params)| recorded(id, params))) +} + +/// Append projected steps without changing existing flags or their indices. +fn append_recorded_steps(action: &mut Action, steps: Vec<(String, Value)>) { + let first = action.steps.len(); + action.dialogs.extend(steps.iter().enumerate().filter(|(_, (id, _))| RECORDED_WITH_DIALOG.contains(&id.as_str())).map(|(i, _)| first + i)); + action.steps.extend(steps); +} + /// Consecutive Move-tool keystrokes in one UI nudge run are one relative action step. /// The UI's `coalesce` run id distinguishes a held key from a later, separate gesture. fn fold_recorded_nudges(steps: impl IntoIterator) -> Vec<(String, Value)> { diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index 9ec48a740c3..972a439be68 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -58,7 +58,7 @@ fn owned<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<&'a Draft> { s.actions.editor.as_ref().filter(|draft| draft.id == id).ok_or_else(|| bad(cmd, "this editor is no longer open")) } -/// Pending capture uses the same logical-command filter as Classic recording. +/// Raw replayable capture is counted before projection to retain the editor's allocation bound. fn pending<'a>(s: &'a Session, draft: &Draft) -> impl Iterator { s.journal.iter().skip(draft.recording.unwrap_or(s.journal.len())).filter(|(id, _)| replayable(id)) } @@ -70,7 +70,7 @@ fn visible(s: &Session, draft: &Draft) -> Result { return Err(bad(RECORD, "the draft exceeds the 100000-step limit")); } let mut value = draft.value.clone(); - value.steps.extend(pending(s, draft).cloned()); + super::append_recorded_steps(&mut value, super::project_recorded_steps(pending(s, draft).cloned())); Ok(value) } diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index ccb42693d7a..02cbd051e90 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -1322,3 +1322,121 @@ fn record_keeps_an_empty_loaded_sets_exact_name() { assert_eq!(crate::actions_edit_cmds::set_names(&s), [" Web "], "one folder, not a trimmed second one"); assert_eq!(s.execute("actions.save", json!({"set": " Web "})).unwrap()["actions"], json!(["First"])); } + +#[test] +fn draft_capture_spelling_uses_classic_filter_and_dialog_offsets() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":300,"height":100,"background":"white"})).unwrap(); + let layer = s.execute("type.create", json!({"x":10,"y":50,"text":"Teh brwon end","size":12})).unwrap()["layer"].as_u64().unwrap(); + s.execute("actions.record", json!({"name":"Spell draft"})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Text"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + s.execute("actions.toggle", json!({"action":0,"step":0,"enabled":false})).unwrap(); + s.execute("actions.dialog", json!({"action":0,"step":0,"enabled":true})).unwrap(); + let original = s.actions.list[0].clone(); + let draft = s.execute("actions.editor.open", json!({"action":0})).unwrap()["draft"].as_u64().unwrap(); + s.execute("actions.editor.record", json!({"draft":draft})).unwrap(); + for params in [ + json!({"action":"list","allLayers":true}), + json!({"action":"suggest","word":"recieve"}), + json!({"action":"change","layer":layer,"start":0,"end":3,"word":"Teh","replace":"The"}), + json!({"action":"changeAll","word":"brwon","replace":"brown"}), + json!({"action":"addToDictionary","word":"Photocraft"}), + json!({"action":"removeFromDictionary","word":"Photocraft"}), + json!({"allLayers":true}), + json!({"action":null,"allLayers":false}), + ] { + s.execute("edit.checkSpelling", params).unwrap(); + } + assert!(matches!( + &s.active().unwrap().doc.layer(photocraft_doc::LayerId(layer)).unwrap().content, + photocraft_doc::LayerContent::Text(text) if text.text == "The brown end" + )); + let journal = s.journal.clone(); + let (take, rev) = (s.actions.take, s.actions.rev); + let shown = s.execute("actions.editor.get", json!({"draft":draft})).unwrap(); + assert_eq!(s.execute("actions.editor.get", json!({"draft":draft})).unwrap(), shown); + let mut expected = original.clone(); + expected.steps.extend([("edit.checkSpelling".into(), json!({"allLayers":true})), ("edit.checkSpelling".into(), json!({"allLayers":false}))]); + expected.dialogs.extend([1, 2]); + assert_eq!(shown["action"], serde_json::to_value(&expected).unwrap()); + assert_eq!(s.actions.list[0], original, "inspection must not publish"); + assert_eq!(s.journal, journal); + assert_eq!((s.actions.take, s.actions.rev), (take, rev)); + s.execute("actions.editor.stop", json!({"draft":draft})).unwrap(); + assert_eq!(s.execute("actions.editor.get", json!({"draft":draft})).unwrap()["action"], shown["action"]); + s.execute("actions.editor.save", json!({"draft":draft})).unwrap(); + assert_eq!(s.actions.list[0], expected, "Save must retain the projected steps and existing flags"); +} + +#[test] +fn draft_capture_nudges_match_classic_rounding_runs_and_separators() { + let capture = |draft_capture: bool| { + let mut s = Session::new(); + s.execute("file.new", json!({"width":24,"height":24,"background":"transparent"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Target"})).unwrap(); + let draft = if draft_capture { + let id = s.execute("actions.editor.open", json!({"name":"Nudges"})).unwrap()["draft"].as_u64().unwrap(); + s.execute("actions.editor.record", json!({"draft":id})).unwrap(); + Some(id) + } else { + s.execute("actions.record", json!({"name":"Nudges"})).unwrap(); + None + }; + for params in + [json!({"dx":0.6,"dy":0,"coalesce":"nudge:1"}), json!({"dx":0.6,"dy":0,"coalesce":"nudge:1"}), json!({"dx":0,"dy":-0.6,"coalesce":"nudge:2"})] + { + s.execute("layer.translate", params).unwrap(); + } + s.execute("layer.renameLayer", json!({"name":"Renamed"})).unwrap(); + s.execute("layer.translate", json!({"dx":0,"dy":-0.6,"coalesce":"nudge:2"})).unwrap(); + match draft { + Some(id) => { + // Save directly from capture uses the same projection as Get and Stop. + let shown = s.execute("actions.editor.get", json!({"draft":id})).unwrap(); + s.execute("actions.editor.save", json!({"draft":id})).unwrap(); + assert_eq!(serde_json::to_value(&s.actions.list[0]).unwrap(), shown["action"]); + } + None => { + s.execute("actions.stop", json!({})).unwrap(); + } + } + s.actions.list[0].clone() + }; + let expected: Vec<(String, Value)> = vec![ + ("layer.translate".into(), json!({"dx":2.0,"dy":0.0})), + ("layer.translate".into(), json!({"dx":0.0,"dy":-1.0})), + ("layer.renameLayer".into(), json!({"name":"Renamed"})), + ("layer.translate".into(), json!({"dx":0.0,"dy":-1.0})), + ]; + let (classic, draft) = (capture(false), capture(true)); + assert_eq!(classic.steps, expected); + assert_eq!(draft.steps, expected, "fold within a run, never across a new run or another command"); + assert_eq!(draft.dialogs, classic.dialogs); + assert!(draft.steps.iter().all(|(_, params)| params.get("coalesce").is_none())); +} + +#[test] +fn draft_capture_preview_flush_keeps_projection_and_recording_owner() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":300,"height":100,"background":"white"})).unwrap(); + s.execute("type.create", json!({"x":10,"y":50,"text":"Teh end","size":12})).unwrap(); + let draft = s.execute("actions.editor.open", json!({"name":"Preview"})).unwrap()["draft"].as_u64().unwrap(); + s.execute("actions.editor.record", json!({"draft":draft})).unwrap(); + s.execute("edit.checkSpelling", json!({"action":"suggest","word":"recieve"})).unwrap(); + s.execute("edit.checkSpelling", json!({"allLayers":true})).unwrap(); + let shown = s.execute("actions.editor.get", json!({"draft":draft})).unwrap(); + let take = s.actions.take; + // Acquire the existing engine snapshot API without executing its document steps. + let (snapshot, owner) = playback::preview(&mut s, draft, 0).unwrap(); + assert_eq!(serde_json::to_value(snapshot.as_ref()).unwrap(), shown["action"]); + owner.validate(&s).unwrap(); + owner.finish(&mut s); + assert_eq!(s.actions.take, take); + assert!(s.actions.recording_active()); + assert!(s.actions.list.is_empty()); + let after = s.execute("actions.editor.get", json!({"draft":draft})).unwrap(); + assert_eq!(after["action"], shown["action"], "suspend flush must not lose flags or duplicate capture"); + assert_eq!(after["action"]["steps"], json!([["edit.checkSpelling",{"allLayers":true}]])); + assert_eq!(after["action"]["dialogs"], json!([0])); +} From cc0781c0315acfa7a1fbcecae2cc5118f76a3310 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 18:56:13 -0800 Subject: [PATCH 085/384] Describe the implemented ATN library in editor documentation --- docs/atn.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 9bf0495cc04..dcbaaecc332 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -95,19 +95,16 @@ The backend editor commands keep one complete action draft outside the live libr restore an old library snapshot, erase the journal or undo document edits made while recording. Old editor identities cannot mutate a reopened editor. -Descriptions and icons temporarily persist through the existing native JSON Actions -library compatibility path, with defaults for older files. The required #11066 library -migration makes ATN with the measured sidecar the sole writable persisted library. -Legacy JSON is then a one-time migration input with a preserved backup, without -continued dual writing or new JSON-only metadata handling. +Descriptions and icons use the same ATN library and metadata sidecar as Load/Save +Actions. The one-time JSON migration and recovery rules above apply to editor +changes too. Actions belong to that library, not to `.pcraft` documents. The integrated ATN sidecar reader and writer preserve descriptions, icons and opaque imported metadata. Unedited physical set rows, including empty and duplicate-name rows, retain their positional carriers; deliberate logical group/action moves retain panel ordering. Editing a description preserves unknown thumbnail data; an explicitly supplied icon choice replaces it. `skipUnsupported` permits omitting unsupported steps, not silently -dropping sidecar metadata. This ATN transport support does not implement the separate -#11066 durable-library migration or authorize normalizing its untouched names/bytes. +dropping sidecar metadata. Untouched names and record bytes remain preserved. The Modern editor stages changes in an unpublished draft. Save publishes the edited action; Discard leaves the library unchanged, and Cancel keeps editing. @@ -120,8 +117,7 @@ waits for existing jobs before taking capture; jobs started by its step dialogs the normal completion/failure and foreign-take handling. The raw legacy `begin_playback` entry still refuses an active draft recorder; the owned adapter suspends it explicitly. Headless `Session::execute(actions.play)` and deliberate shell library calls capture -one named call in either recorder. No #10766 -or #10965 implementation is implicitly imported by this backend. +one named call in either recorder. ## Supported events From d630efdce27ec7170dc842438f013452d5fb8df7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:22:40 -0800 Subject: [PATCH 086/384] Use whole-edge transform handles and consistent rotate cursors --- .../ui-egui/src/canvas/transform_controls.rs | 62 +++--- .../ui-egui/src/tools/transform_tool/draw.rs | 14 +- .../src/tools/transform_tool/pointer.rs | 120 +++++++---- .../ui-egui/src/transform_hit_zone_tests.rs | 186 ++++++++++++++++++ 4 files changed, 304 insertions(+), 78 deletions(-) create mode 100644 crates/ui-egui/src/transform_hit_zone_tests.rs diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 7e00fd8cb39..d0ad6ee752a 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -58,50 +58,40 @@ pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Op Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32))) } -/// The Move tool shows transform handles even before a Free Transform session exists. -/// Use the same hit areas for pointer-down and hover, so a control never behaves like -/// resize/rotate while showing the ordinary four-arrow Move cursor (#2871). +fn controls_quad(r: Rect) -> [[f64; 2]; 4] { + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| [f64::from(p.x), f64::from(p.y)]) +} + +fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { + crate::state::TransformSession::box_hit(&controls_quad(r), [f64::from(p.x), f64::from(p.y)], crate::transform_tool::HANDLE_PX) +} + +/// Share the open session's screen-sized zones before the first interaction (#11097). pub(super) fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { - let handles = [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()]; - let grab = crate::transform_tool::HANDLE_PX as f32; - for (i, handle) in handles.into_iter().enumerate() { - if handle.distance(p) <= grab { - return Some(match i { - 0 | 4 => egui::CursorIcon::ResizeNwSe, - 2 | 6 => egui::CursorIcon::ResizeNeSw, - 1 | 5 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - }); - } - } - // The rotation band: the pointer gives way to the curved rotate arrow (`transform_controls_turn`). - in_rotation_band(r, p).then_some(egui::CursorIcon::None) + use crate::transform_tool::Hit; + Some(match controls_zone(r, p) { + Hit::Corner(0 | 2) => egui::CursorIcon::ResizeNwSe, + Hit::Corner(_) => egui::CursorIcon::ResizeNeSw, + Hit::Edge(0 | 2) => egui::CursorIcon::ResizeVertical, + Hit::Edge(_) => egui::CursorIcon::ResizeHorizontal, + Hit::Outside => egui::CursorIcon::None, + Hit::Inside | Hit::Pivot => egui::CursorIcon::Default, + }) } -fn in_rotation_band(r: Rect, p: Pos2) -> bool { - r.expand(18.0).contains(p) && !r.expand(5.0).contains(p) -} - -/// In the rotation band just outside the Move tool's transform controls, the screen direction the -/// curved rotate arrow bends round: into the box along the nearest corner's bisector, as Free -/// Transform draws it (#4658). Before, the band showed a plain alias pointer, so nothing said a -/// drag there rotates. +/// At any distance outside the controls, the same edge/corner direction as Free Transform. pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { - if !in_rotation_band(r, p) - || [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] - .into_iter() - .any(|h| h.distance(p) <= crate::transform_tool::HANDLE_PX as f32) - { + if controls_zone(r, p) != crate::transform_tool::Hit::Outside { return None; } - let corner = pos2(if p.x < r.center().x { r.left() } else { r.right() }, if p.y < r.center().y { r.top() } else { r.bottom() }); - Some(vec2((r.center().x - corner.x).signum(), (r.center().y - corner.y).signum()).normalized() * 16.0) + let k = crate::state::TransformSession::box_turn_direction(&controls_quad(r), [f64::from(p.x), f64::from(p.y)])?; + Some(vec2(k[0] as f32, k[1] as f32) * 16.0) } -/// A visible handle starts scaling; the narrow band just outside the box starts rotation. +/// A visible handle starts scaling; any point outside the box starts rotation. /// The interior stays the normal Move-tool drag target. pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { - transform_controls_cursor(r, p).is_some() + !matches!(controls_zone(r, p), crate::transform_tool::Hit::Inside | crate::transform_tool::Hit::Pivot) } /// Enter the existing Free Transform session when a Move-tool transform control is pressed: on @@ -138,3 +128,7 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside); } } + +#[cfg(test)] +#[path = "../transform_hit_zone_tests.rs"] +mod hit_zone_tests; diff --git a/crates/ui-egui/src/tools/transform_tool/draw.rs b/crates/ui-egui/src/tools/transform_tool/draw.rs index 26807afed62..21c14cb86ff 100644 --- a/crates/ui-egui/src/tools/transform_tool/draw.rs +++ b/crates/ui-egui/src/tools/transform_tool/draw.rs @@ -1,11 +1,11 @@ use super::begin::warps_type; use super::drag_modes::{HandleIndicator, handle_indicator}; use super::options_bar::{quad_scale, readout}; -use super::pointer::{Hit, handle_at, rotate_dir, rotation_degrees}; +use super::pointer::{Hit, handle_at, rotation_degrees}; use super::warp::{on_section_edge, quick_split_kind, single_patch}; use super::{ - BezierMesh, Color32, DocId, HANDLE_PX, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, - WarpDrag, WarpStyle, corners, pos2, vec2, + BezierMesh, Color32, DocId, Homography, LayerId, PhotocraftApp, Pos2, SplitTool, Stroke, TransformPreview, TransformSession, ViewXform, Warp, WarpDrag, + WarpStyle, corners, pos2, vec2, }; /// The distort or skew pointer at screen point `at`, where [`super::cursor`] hid the system @@ -212,14 +212,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], Stroke::new(1.0, Color32::WHITE)); painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::WHITE)); if let Some(at) = painter.ctx().pointer_hover_pos().filter(|p| xf.rect.contains(*p)) { - let p = xf.to_doc(at); - let tol = HANDLE_PX / f64::from(xf.zoom.max(0.01)); - if let Some(k) = rotate_dir(t, Some(pv), p, tol) { - // Through the view, so the arc bends round the corner as it shows under a turned or - // flipped view. - let toward = xf.to_screen((p[0] + k[0] * 16.0) as f32, (p[1] + k[1] * 16.0) as f32) - at; - crate::crop_ui::draw_turn_cursor(painter.ctx(), at, toward); - } // Preferences › Tools › Show Transformation Values off hides it, as in Photoshop (#204). if let Some(r) = live_readout(app).filter(|_| app.session.prefs().tools.show_transformation_values) { draw_live_readout(app, painter.ctx(), at, r); diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index dd76e446c9b..6f9186164a6 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -17,21 +17,91 @@ pub(crate) enum Hit { Outside, } -/// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or -/// outside the box. +/// Screen-sized corner and outward edge bands, shared with Move's unopened controls. +impl TransformSession { + pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { + // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. + let corner_radius = tol * (8.0 / HANDLE_PX); + let edge_width = tol * (11.0 / HANDLE_PX); + let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); + if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { + return Hit::Corner(i); + } + let interior = inside(quad, p); + let edge = (0..4) + .filter_map(|i| { + let (_, q) = edge_projection(quad, i, p)?; + let d = distance(q); + // One pixel inside is interior, not a scale handle. The line itself is a handle. + (d <= edge_width && (!interior || d <= tol.abs() * 1e-9)).then_some((i, d)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + match edge { + Some((i, _)) => Hit::Edge(i), + None if interior => Hit::Inside, + None => Hit::Outside, + } + } + + /// Into the nearest edge or corner: eight orientations for an upright rectangle, following + /// the actual quad when it is rotated, flipped or distorted. + pub(crate) fn box_turn_direction(quad: &[[f64; 2]; 4], p: [f64; 2]) -> Option<[f64; 2]> { + if p.iter().chain(quad.iter().flatten()).any(|v| !v.is_finite()) { + return None; + } + let (i, u, _) = (0..4) + .filter_map(|i| { + let (u, q) = edge_projection(quad, i, p)?; + Some((i, u, (p[0] - q[0]).hypot(p[1] - q[1]))) + }) + .min_by(|a, b| a.2.total_cmp(&b.2))?; + let unit = |v: [f64; 2]| { + let len = v[0].hypot(v[1]); + (len.is_finite() && len > 1e-9).then(|| [v[0] / len, v[1] / len]) + }; + let toward = if u <= 0.0 || u >= 1.0 { + let i = if u >= 1.0 { (i + 1) % 4 } else { i }; + let direction = |j: usize| unit([quad[j][0] - quad[i][0], quad[j][1] - quad[i][1]]); + let (a, b) = (direction((i + 1) % 4)?, direction((i + 3) % 4)?); + [a[0] + b[0], a[1] + b[1]] + } else { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let normal = [a[1] - b[1], b[0] - a[0]]; + let center = quad.iter().fold([0.0; 2], |c, q| [c[0] + q[0] / 4.0, c[1] + q[1] / 4.0]); + let sign = (normal[0] * (center[0] - a[0]) + normal[1] * (center[1] - a[1])).signum(); + normal.map(|v| v * sign) + }; + unit(toward) + } +} + +/// The nearest point on an edge segment and its clamped parameter. Degenerate edges have no +/// band or normal; the other edges and corner handles remain usable. +fn edge_projection(quad: &[[f64; 2]; 4], i: usize, p: [f64; 2]) -> Option<(f64, [f64; 2])> { + let (a, b) = (quad[i], quad[(i + 1) % 4]); + let d = [b[0] - a[0], b[1] - a[1]]; + let len2 = d[0] * d[0] + d[1] * d[1]; + if !len2.is_finite() || len2 <= 1e-18 { + return None; + } + let u = (((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / len2).clamp(0.0, 1.0); + Some((u, [a[0] + u * d[0], a[1] + u * d[1]])) +} + +/// The shared box zones, retaining the nearest pivot's existing grab priority on small boxes. pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { let d = |a: [f64; 2]| ((a[0] - p[0]).powi(2) + (a[1] - p[1]).powi(2)).sqrt(); let mid = |i: usize| { let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]); [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0] }; - let handles = (0..4).map(|i| (t.quad[i], Hit::Corner(i))).chain((0..4).map(|i| (mid(i), Hit::Edge(i)))).chain([(t.pivot, Hit::Pivot)]); - let nearest = handles.map(|(q, h)| (d(q), h)).filter(|(dist, _)| *dist < tol).min_by(|a, b| a.0.total_cmp(&b.0)); - match nearest { - Some((_, h)) => h, - None if inside(&t.quad, p) => Hit::Inside, - None => Hit::Outside, - } + let h = TransformSession::box_hit(&t.quad, p, tol); + let handle_distance = match h { + Hit::Corner(i) => d(t.quad[i]), + Hit::Edge(i) => d(mid(i)), + _ => f64::INFINITY, + }; + if d(t.pivot) < tol && d(t.pivot) < handle_distance { Hit::Pivot } else { h } } fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { @@ -208,16 +278,12 @@ pub(super) fn carry_pivot(quad0: [[f64; 2]; 4], quad: [[f64; 2]; 4], pivot0: [f6 if x.is_finite() && y.is_finite() { [x, y] } else { pivot0 } } -/// True just outside a corner: rotate handle feedback lives here, not over the entire -/// empty canvas. The actual rotation gesture continues to work farther away. +/// Rotate feedback covers every outside point where a rotation gesture can start. pub(super) fn near_rotate_corner(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { - t.warp.is_none() - && t.mode != TransformMode::Distort - && { - let h = hit(t, p, tol); - h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) - } - && t.quad.iter().any(|q| (p[0] - q[0]).hypot(p[1] - q[1]) <= tol * 3.0) + t.warp.is_none() && t.mode != TransformMode::Distort && { + let h = hit(t, p, tol); + h == Hit::Outside || (t.mode == TransformMode::Rotate && matches!(h, Hit::Corner(_) | Hit::Edge(_))) + } } /// Clockwise degrees in canvas coordinates (positive Y points down). @@ -228,8 +294,7 @@ pub(super) fn rotation_degrees(quad: [[f64; 2]; 4]) -> f64 { } /// Where the rotate cursor's arc bends at document point `p`, or `None` outside the rotate zone: -/// just outside a corner, or anywhere while a rotate drag is on. It points into the box along the -/// bisector of the nearest corner, so each corner gets its own arrow and they turn with the box. +/// anywhere outside, or while a rotate drag is on. It follows the nearest edge or corner. pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64; 2]> { rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) } @@ -251,17 +316,7 @@ pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: None if !near_rotate_corner(t, p, tol) => return None, None => {} } - let dist = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); - let i = (0..4).min_by(|&a, &b| dist(t.quad[a]).total_cmp(&dist(t.quad[b])))?; - let unit = |to: [f64; 2]| { - let d = [to[0] - t.quad[i][0], to[1] - t.quad[i][1]]; - let l = d[0].hypot(d[1]); - (l.is_finite() && l > 1e-9).then(|| [d[0] / l, d[1] / l]) - }; - let (a, b) = (unit(t.quad[(i + 1) % 4])?, unit(t.quad[(i + 3) % 4])?); - let s = [a[0] + b[0], a[1] + b[1]]; - let l = s[0].hypot(s[1]); - (l.is_finite() && l > 1e-9).then(|| [s[0] / l, s[1] / l]) + TransformSession::box_turn_direction(&t.quad, p) } /// The handle a drag in progress holds, else the one under document point `p`. @@ -317,7 +372,6 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], mods: egui::Modi Hit::Corner(i) => crate::tool_cursor::corner_resize(&t.quad, i, xf), Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, - Hit::Inside => CursorIcon::Move, - Hit::Outside => CursorIcon::Alias, + Hit::Inside | Hit::Outside => CursorIcon::Default, }) } diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs new file mode 100644 index 00000000000..a388f6c909e --- /dev/null +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -0,0 +1,186 @@ +use super::*; +use crate::state::{ContentAwareOptions, TransformMode, TransformSession}; +use crate::transform_tool::{Hit, cursor, hit, rotate_cursor_dir}; +use egui::{CursorIcon, Modifiers}; + +const QUAD: [[f64; 2]; 4] = [[100.0, 100.0], [500.0, 100.0], [500.0, 300.0], [100.0, 300.0]]; + +fn session(quad: [[f64; 2]; 4]) -> TransformSession { + TransformSession { + session: 1, + layer: 1, + rect: [100.0, 100.0, 500.0, 300.0], + quad, + pivot: quad.iter().fold([0.0; 2], |c, p| [c[0] + p[0] / 4.0, c[1] + p[1] / 4.0]), + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + path: None, + made: None, + mode: TransformMode::Free, + content_aware: None, + skew_v: 0.0, + layers: Vec::new(), + } +} + +fn app(quad: [[f64; 2]; 4], zoom: f32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.transform = Some(session(quad)); + app +} + +fn view(zoom: f32) -> ViewXform { + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0)), zoom, center: [300.0, 200.0], flip: false, rotation: 0.0, aspect: 1.0 } +} + +fn near(actual: Option<[f64; 2]>, expected: [f64; 2]) { + let actual = actual.expect("rotate direction"); + assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1e-6, "{actual:?} != {expected:?}"); +} + +#[test] +fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { + for zoom in [1.0, 2.0] { + let q = QUAD.map(|p| p.map(|v| v / zoom)); + let t = session(q); + // Coordinates are screen pixels, so the same table must hold at both document zooms. + for (p, expected) in [ + ([100.0, 100.0], Hit::Corner(0)), + ([105.0, 105.0], Hit::Corner(0)), + ([95.0, 95.0], Hit::Corner(0)), + ([500.0, 300.0], Hit::Corner(2)), + ([180.0, 100.0], Hit::Edge(0)), + ([180.0, 89.0], Hit::Edge(0)), + ([500.0, 150.0], Hit::Edge(1)), + ([511.0, 150.0], Hit::Edge(1)), + ([420.0, 311.0], Hit::Edge(2)), + ([89.0, 250.0], Hit::Edge(3)), + ([180.0, 101.0], Hit::Inside), + ([499.0, 150.0], Hit::Inside), + ([180.0, 180.0], Hit::Inside), + ([180.0, 88.0], Hit::Outside), + ([620.0, 200.0], Hit::Outside), + ] { + assert_eq!(hit(&t, p.map(|v| v / zoom), 12.0 / zoom), expected, "zoom {zoom}, screen {p:?}"); + } + let tiny = session([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); + assert_eq!(TransformSession::box_hit(&tiny.quad, [5.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + } +} + +#[test] +fn turned_box_zones_and_resize_cursors_follow_the_quad() { + let turn = |p: [f64; 2]| { + let (x, y) = (p[0] - 300.0, p[1] - 200.0); + let k = std::f64::consts::FRAC_1_SQRT_2; + [300.0 + (x - y) * k, 200.0 + (x + y) * k] + }; + for zoom in [1.0_f32, 2.0] { + let scale = |p: [f64; 2]| turn(p).map(|v| v / f64::from(zoom)); + let q = QUAD.map(scale); + let app = app(q, zoom); + let xf = view(zoom); + let t = app.ui.transform.as_ref().unwrap(); + for (p, expected, icon) in [ + ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), + ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), + ([180.0, 101.0], Hit::Inside, CursorIcon::Default), + ([180.0, -20.0], Hit::Outside, CursorIcon::None), + ] { + assert_eq!(hit(t, scale(p), 12.0 / f64::from(zoom)), expected); + assert_eq!(cursor(&app, &xf, scale(p), Modifiers::NONE), Some(icon), "zoom {zoom}, {p:?}"); + } + assert_eq!(hit(t, scale([100.0, 100.0]), 12.0 / f64::from(zoom)), Hit::Corner(0)); + } +} + +#[test] +fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { + let q = [[100.0, 100.0], [500.0, 140.0], [460.0, 300.0], [80.0, 260.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + let t = session(q); + assert_eq!(hit(&t, [220.0, 112.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 107.0], 12.0), Hit::Edge(0)); + assert_eq!(hit(&t, [220.0, 113.0], 12.0), Hit::Inside); + assert_eq!(hit(&t, [220.0, 90.0], 12.0), Hit::Outside); + near(TransformSession::box_turn_direction(&q, [220.0, 90.0]), [-0.1 / 1.01_f64.sqrt(), 1.0 / 1.01_f64.sqrt()]); + } +} + +#[test] +fn outside_rotation_has_eight_edge_and_corner_orientations() { + let app = app(QUAD, 1.0); + let h = std::f64::consts::FRAC_1_SQRT_2; + for (p, direction) in [ + ([300.0, -20.0], [0.0, 1.0]), + ([620.0, -20.0], [-h, h]), + ([620.0, 200.0], [-1.0, 0.0]), + ([620.0, 420.0], [-h, -h]), + ([300.0, 420.0], [0.0, -1.0]), + ([-20.0, 420.0], [h, -h]), + ([-20.0, 200.0], [1.0, 0.0]), + ([-20.0, -20.0], [h, h]), + ] { + near(rotate_cursor_dir(&app, p), direction); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::NONE), Some(CursorIcon::None)); + assert_eq!(cursor(&app, &view(1.0), p, Modifiers::COMMAND), Some(CursorIcon::None)); + } +} + +#[test] +fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { + let app = app(QUAD, 1.0); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for p in [pos2(100.0, 100.0), pos2(180.0, 95.0), pos2(505.0, 150.0), pos2(180.0, 101.0), pos2(620.0, 200.0), pos2(-20.0, -20.0)] { + let d = [f64::from(p.x), f64::from(p.y)]; + assert_eq!(transform_controls_cursor(r, p), cursor(&app, &view(1.0), d, Modifiers::NONE), "{p:?}"); + assert_eq!(transform_controls_hit(r, p), hit(app.ui.transform.as_ref().unwrap(), d, 12.0) != Hit::Inside); + match (transform_controls_turn(r, p), rotate_cursor_dir(&app, d)) { + (None, None) => {} + (Some(a), Some(b)) => near(Some([f64::from(a.x) / 16.0, f64::from(a.y) / 16.0]), b), + pair => panic!("unopened/open rotation disagreement: {pair:?}"), + } + } + assert_eq!(transform_controls_cursor(r, r.center()), Some(CursorIcon::Default)); + assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); +} + +#[test] +fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { + let mut app = app(QUAD, 1.0); + for mode in [TransformMode::Distort, TransformMode::Skew] { + app.ui.transform.as_mut().unwrap().mode = mode; + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + } + app.ui.transform.as_mut().unwrap().mode = TransformMode::Free; + app.ui.transform.as_mut().unwrap().content_aware = Some(ContentAwareOptions::default()); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Default)); + app.ui.transform.as_mut().unwrap().content_aware = None; + let t = app.ui.transform.as_mut().unwrap(); + t.warp = Some(photocraft_geom::warp::Warp::none(t.rect)); + assert_eq!(rotate_cursor_dir(&app, [620.0, 200.0]), None); + assert_eq!(cursor(&app, &view(1.0), [620.0, 200.0], Modifiers::NONE), Some(CursorIcon::Crosshair), "Warp retains its own cursor"); + app.ui.transform.as_mut().unwrap().warp = None; + assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 200.0]), None); + assert_eq!(TransformSession::box_turn_direction(&[[0.0, 0.0]; 4], [20.0, 20.0]), None); +} + +#[test] +fn rotate_drag_keeps_the_arrow_when_the_pointer_enters_the_box() { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [4, 4, 16, 16], "fill": "#ff0000"})).unwrap(); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Down { x: 80.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + crate::transform_tool::pointer(&mut app, crate::canvas::ToolEvent::Move { x: 10.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + assert!(rotate_cursor_dir(&app, [10.0, 10.0]).is_some(), "the held rotation gesture owns the arrow even inside"); + assert_eq!(cursor(&app, &view(1.0), [10.0, 10.0], Modifiers::NONE), Some(CursorIcon::None)); +} From 6eb625ccbfd8fcfa6f27e5de2f58323d490f85e9 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:35:45 -0800 Subject: [PATCH 087/384] Orient transform rotate arrows by quad winding --- crates/ui-egui/src/tools/transform_tool/pointer.rs | 6 ++++-- crates/ui-egui/src/transform_hit_zone_tests.rs | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 6f9186164a6..781e02ec207 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -67,8 +67,10 @@ impl TransformSession { } else { let (a, b) = (quad[i], quad[(i + 1) % 4]); let normal = [a[1] - b[1], b[0] - a[0]]; - let center = quad.iter().fold([0.0; 2], |c, q| [c[0] + q[0] / 4.0, c[1] + q[1] / 4.0]); - let sign = (normal[0] * (center[0] - a[0]) + normal[1] * (center[1] - a[1])).signum(); + // Winding orients every edge, even when a concave quad's mean corner position + // lies on the wrong side of this edge. Translate before taking the area. + let cross = |i: usize, j: usize| (quad[i][0] - quad[0][0]) * (quad[j][1] - quad[0][1]) - (quad[i][1] - quad[0][1]) * (quad[j][0] - quad[0][0]); + let sign = (cross(1, 2) + cross(2, 3)).signum(); normal.map(|v| v * sign) }; unit(toward) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index a388f6c909e..397cef40c8a 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -87,6 +87,7 @@ fn turned_box_zones_and_resize_cursors_follow_the_quad() { let xf = view(zoom); let t = app.ui.transform.as_ref().unwrap(); for (p, expected, icon) in [ + ([500.0, 100.0], Hit::Corner(1), CursorIcon::ResizeHorizontal), ([180.0, 95.0], Hit::Edge(0), CursorIcon::ResizeNeSw), ([505.0, 150.0], Hit::Edge(1), CursorIcon::ResizeNwSe), ([180.0, 101.0], Hit::Inside, CursorIcon::Default), @@ -112,6 +113,14 @@ fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { } } +#[test] +fn concave_quad_edge_direction_uses_winding_in_both_orientations() { + let q = [[0.0, 0.0], [100.0, 0.0], [10.0, 10.0], [0.0, 100.0]]; + for q in [q, [q[1], q[0], q[3], q[2]]] { + near(TransformSession::box_turn_direction(&q, [56.0, 14.0]), [-1.0 / 82.0_f64.sqrt(), -9.0 / 82.0_f64.sqrt()]); + } +} + #[test] fn outside_rotation_has_eight_edge_and_corner_orientations() { let app = app(QUAD, 1.0); From 5e1e4b85026c930d0692aec68ca8817c03b3a3d4 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:36:59 -0800 Subject: [PATCH 088/384] Keep the transform controls point constructor test-only --- crates/ui-egui/src/canvas/transform_controls.rs | 2 +- crates/ui-egui/src/transform_hit_zone_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index d0ad6ee752a..68f37fb5b97 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -1,6 +1,6 @@ use super::selection::floating_outline; use super::view_xform::ViewXform; -use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, pos2, vec2}; +use super::{Color32, LayerContent, PhotocraftApp, Pos2, Rect, Stroke, Tool, Vec2, vec2}; /// View › Show › Layer Edges: the colour of the active layer's outline. pub(crate) const LAYER_EDGES: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 397cef40c8a..ca08d2f012f 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::state::{ContentAwareOptions, TransformMode, TransformSession}; use crate::transform_tool::{Hit, cursor, hit, rotate_cursor_dir}; -use egui::{CursorIcon, Modifiers}; +use egui::{CursorIcon, Modifiers, pos2}; const QUAD: [[f64; 2]; 4] = [[100.0, 100.0], [500.0, 100.0], [500.0, 300.0], [100.0, 300.0]]; From 4edd0914b0d620923241061889a3a20c6a839287 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:49:56 -0800 Subject: [PATCH 089/384] Exercise unopened Move edge controls through native dispatch --- .../ui-egui/src/transform_hit_zone_tests.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index ca08d2f012f..cd0e8188530 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -159,6 +159,39 @@ fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); } +#[test] +fn move_whole_edge_press_enters_scaling_at_both_zooms() { + for zoom in [1.0, 2.0] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", serde_json::json!({"width": 640, "height": 480})).unwrap(); + app.session.execute("shape.create", serde_json::json!({"kind": "rect", "rect": [100, 100, 400, 200], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = zoom; + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + app.ui.extras.snap = false; + let xf = view(zoom); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + // Away from the top edge's midpoint and corners: the native canvas first opens the + // controls here, then forwards the same document-space press to the transform tool. + assert!(begin_transform_controls_at(&mut app, &ctx, &xf, xf.to_screen(180.0, 100.0))); + for event in [ + crate::canvas::ToolEvent::Down { x: 180.0, y: 100.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 180.0, y: 90.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 180.0, y: 90.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::SHIFT); + } + let t = app.ui.transform.as_ref().expect("a handle release retains the transform session"); + assert!(t.quad[0][1] < 100.0, "the pressed edge scales"); + assert!((t.quad[2][1] - 300.0).abs() < 1e-6, "the opposite edge anchors scaling instead of moving the whole box"); + assert_eq!(app.session.active().unwrap().doc, before, "a preview does not commit pixels"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + } +} + #[test] fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { let mut app = app(QUAD, 1.0); From 49575e3a815e2affca3361a6d1d807dd74458509 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 19:51:50 -0800 Subject: [PATCH 090/384] Keep invalid points outside Move transform control activation --- crates/ui-egui/src/canvas/transform_controls.rs | 5 ++++- crates/ui-egui/src/transform_hit_zone_tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 68f37fb5b97..5a26a5a281e 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -68,6 +68,9 @@ fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { /// Share the open session's screen-sized zones before the first interaction (#11097). pub(super) fn transform_controls_cursor(r: Rect, p: Pos2) -> Option { + if !p.is_finite() { + return None; + } use crate::transform_tool::Hit; Some(match controls_zone(r, p) { Hit::Corner(0 | 2) => egui::CursorIcon::ResizeNwSe, @@ -91,7 +94,7 @@ pub(super) fn transform_controls_turn(r: Rect, p: Pos2) -> Option { /// A visible handle starts scaling; any point outside the box starts rotation. /// The interior stays the normal Move-tool drag target. pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool { - !matches!(controls_zone(r, p), crate::transform_tool::Hit::Inside | crate::transform_tool::Hit::Pivot) + p.is_finite() && !matches!(controls_zone(r, p), crate::transform_tool::Hit::Inside | crate::transform_tool::Hit::Pivot) } /// Enter the existing Free Transform session when a Move-tool transform control is pressed: on diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index cd0e8188530..66d13125b79 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -212,6 +212,12 @@ fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { app.ui.transform.as_mut().unwrap().warp = None; assert_eq!(rotate_cursor_dir(&app, [f64::NAN, 200.0]), None); assert_eq!(TransformSession::box_turn_direction(&[[0.0, 0.0]; 4], [20.0, 20.0]), None); + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 300.0)); + for p in [pos2(f32::NAN, 200.0), pos2(f32::INFINITY, 200.0)] { + assert_eq!(transform_controls_cursor(r, p), None, "invalid points cannot hide the pointer"); + assert_eq!(transform_controls_turn(r, p), None); + assert!(!transform_controls_hit(r, p), "invalid points cannot activate the overlay"); + } } #[test] From c09886c9b240c3edd93a0112984d143b8f335eed Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:06:44 -0800 Subject: [PATCH 091/384] Resolve strict lint in the Actions library integration --- crates/engine/src/cmd/actions_cmds/library.rs | 26 +++++++-------- .../engine/src/cmd/actions_cmds/playback.rs | 4 +-- crates/engine/src/cmd/atn_cmds/convert.rs | 8 ++--- crates/engine/src/cmd/atn_cmds/files.rs | 13 ++++---- crates/engine/src/cmd/atn_cmds/sidecar.rs | 33 ++++++++++--------- crates/format/src/noreplace.rs | 14 ++++---- 6 files changed, 50 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds/library.rs b/crates/engine/src/cmd/actions_cmds/library.rs index b50eeaff327..1410da4d015 100644 --- a/crates/engine/src/cmd/actions_cmds/library.rs +++ b/crates/engine/src/cmd/actions_cmds/library.rs @@ -408,19 +408,19 @@ pub(crate) fn encode(state: &ActionState, keys: &[(String, String)]) -> Result= groups.len() { - if let (Some(outer), Some(source)) = (&outer, &state.library.source) { - if let Some(old) = source.hidden.and_then(|hidden| source.parsed.spans.get(hidden)) { - let descriptor = old - .actions - .first() - .and_then(|action| action.items.first()) - .and_then(|item| item.descriptor.clone()) - .ok_or("missing original sidecar span")?; - append(&mut out, &source.bytes, old.range.start..descriptor.start)?; - append(&mut out, outer, 0..outer.len())?; - append(&mut out, &source.bytes, descriptor.end..old.range.end)?; - continue; - } + if let (Some(outer), Some(source)) = (&outer, &state.library.source) + && let Some(old) = source.hidden.and_then(|hidden| source.parsed.spans.get(hidden)) + { + let descriptor = old + .actions + .first() + .and_then(|action| action.items.first()) + .and_then(|item| item.descriptor.clone()) + .ok_or("missing original sidecar span")?; + append(&mut out, &source.bytes, old.range.start..descriptor.start)?; + append(&mut out, outer, 0..outer.len())?; + append(&mut out, &source.bytes, descriptor.end..old.range.end)?; + continue; } // Newly created wrapper uses the canonical header and the exact patched descriptor. if let Some(outer) = &outer { diff --git a/crates/engine/src/cmd/actions_cmds/playback.rs b/crates/engine/src/cmd/actions_cmds/playback.rs index 53fc59b4a0e..8f476cc4eae 100644 --- a/crates/engine/src/cmd/actions_cmds/playback.rs +++ b/crates/engine/src/cmd/actions_cmds/playback.rs @@ -5,7 +5,7 @@ use super::{Action, Recording, Result, Session, Value, bad}; #[derive(Debug)] enum Destination { - Classic(Action), + Classic(Box), Draft(u64), } @@ -60,7 +60,7 @@ fn suspend(s: &mut Session, preview: Option<(u64, Arc)>, call: Option<(S // Valid Classic capture always names a current action. A missing destination must // fail before acquiring a lease rather than resume into an unrelated row. match s.actions.list.get(index).cloned() { - Some(action) => Some(Destination::Classic(action)), + Some(action) => Some(Destination::Classic(Box::new(action))), None => { s.actions.recording = Some((s.journal.len(), index)); return Err(bad("actions.play", "the recording destination no longer exists")); diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 9b9c6515c33..1dc5afd3b24 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -215,10 +215,10 @@ pub(crate) fn library_items(id: &str, params: &Value, set: &str) -> std::result: if let Some(items) = export_items(id, params, set) { let file = AtnFile { name: UnicodeString::new_nul(set), ..Default::default() }; let action = AtnAction { items: items.clone(), ..Default::default() }; - if let Ok((roundtrip, _)) = library_action(&file, &action, set) { - if roundtrip.steps == [(id.to_string(), params.clone())] { - return Ok(items); - } + if let Ok((roundtrip, _)) = library_action(&file, &action, set) + && roundtrip.steps == [(id.to_string(), params.clone())] + { + return Ok(items); } } Ok(vec![native_item(id, params)?]) diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index ec2e1557e96..a07a809feec 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -227,12 +227,13 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result { sidecar::groups(&s.actions, None).map_err(|error| bad(cmd, error))?.into_iter().map(|(name, selection)| (name.to_string(), selection)).collect() }; // Preserve the old default single-set output name; a multi-set Save keeps each real group. - if p.get("action").is_none() && set_param.is_none() && groups.len() == 1 { - if let Some((name, _)) = groups.first_mut() { - if name != super::UNGROUPED_SET { - *name = DEFAULT_SET.to_string(); - } - } + if p.get("action").is_none() + && set_param.is_none() + && groups.len() == 1 + && let Some((name, _)) = groups.first_mut() + && name != super::UNGROUPED_SET + { + *name = DEFAULT_SET.to_string(); } let names: Vec = groups.iter().flat_map(|(_, selection)| &selection.actions).map(|action| action.name.clone()).collect(); let set_names: Vec = groups.iter().map(|(name, _)| name.clone()).collect(); diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs index b73f9b36c64..4401d6ed664 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -224,10 +224,11 @@ pub(crate) struct SetRowMetadata { /// Root's editor.update hook calls this after validating an explicitly supplied icon string. pub(crate) fn explicit_icon(action: &mut Action) { - if let Some(metadata) = action.atn_metadata.as_mut() { - if action.icon.is_empty() && !matches!(metadata.thumbnail, 0 | 3) { - metadata.reset_icon = true; - } + if let Some(metadata) = action.atn_metadata.as_mut() + && action.icon.is_empty() + && !matches!(metadata.thumbnail, 0 | 3) + { + metadata.reset_icon = true; } } @@ -368,21 +369,21 @@ pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result= atn::MAX_SETS { - return Err("too many sets in actions archive".into()); - } - out.push((name, Selection { actions: Vec::new(), source: empty_source(state, slot), raw: Some(raw.clone()) })); + if let Some(raw) = raw.as_ref() + && !out.iter().skip(before).any(|(_, selection)| selection.raw.as_ref() == Some(raw)) + { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); } + out.push((name, Selection { actions: Vec::new(), source: empty_source(state, slot), raw: Some(raw.clone()) })); } - if let Some(source) = empty_source(state, slot) { - if !out.iter().skip(before).any(|(_, selection)| selection.source.as_ref().is_some_and(|existing| same_set(existing, &source))) { - if out.len() >= atn::MAX_SETS { - return Err("too many sets in actions archive".into()); - } - out.push((name, Selection { actions: Vec::new(), source: Some(source), raw: state.library.rows.get(slot).cloned().flatten() })); + if let Some(source) = empty_source(state, slot) + && !out.iter().skip(before).any(|(_, selection)| selection.source.as_ref().is_some_and(|existing| same_set(existing, &source))) + { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); } + out.push((name, Selection { actions: Vec::new(), source: Some(source), raw: state.library.rows.get(slot).cloned().flatten() })); } } if out.len() == before { diff --git a/crates/format/src/noreplace.rs b/crates/format/src/noreplace.rs index 55acde88517..b43434d9bdb 100644 --- a/crates/format/src/noreplace.rs +++ b/crates/format/src/noreplace.rs @@ -96,12 +96,12 @@ mod tests { struct Dir(PathBuf); impl Dir { - fn new() -> Self { + fn new() -> io::Result { static NEXT: AtomicU64 = AtomicU64::new(0); let path = std::env::temp_dir().join(format!("pc-noreplace-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); // Never clean a preexisting directory to make the fixture pass. - std::fs::create_dir(&path).unwrap(); - Self(path) + std::fs::create_dir(&path)?; + Ok(Self(path)) } } impl Drop for Dir { @@ -112,7 +112,7 @@ mod tests { #[test] fn retirement_moves_the_original_bytes_without_overwriting_a_backup() { - let dir = Dir::new(); + let dir = Dir::new().unwrap(); let from = dir.0.join("legacy"); let to = dir.0.join("retained"); std::fs::write(&from, b"exact original bytes").unwrap(); @@ -128,7 +128,7 @@ mod tests { #[test] fn retirement_refuses_same_name_and_missing_source_without_changing_existing_data() { - let dir = Dir::new(); + let dir = Dir::new().unwrap(); let from = dir.0.join("legacy"); let to = dir.0.join("retained"); std::fs::write(&to, b"existing backup").unwrap(); @@ -140,7 +140,7 @@ mod tests { #[test] fn publication_keeps_an_existing_destination_and_leaves_no_owned_temp() { - let dir = Dir::new(); + let dir = Dir::new().unwrap(); let path = dir.0.join("actions.atn"); std::fs::write(&path, b"existing ATN").unwrap(); let error = write_noreplace(&path, b"replacement").unwrap_err(); @@ -151,7 +151,7 @@ mod tests { #[test] fn publication_creates_complete_bytes_then_removes_its_temp_name() { - let dir = Dir::new(); + let dir = Dir::new().unwrap(); let path = dir.0.join("actions.atn"); write_noreplace(&path, b"complete ATN").unwrap(); assert_eq!(std::fs::read(&path).unwrap(), b"complete ATN"); From 8038f78059c84b71737e2fb1417951c883344994 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:06:44 -0800 Subject: [PATCH 092/384] Align ATN export tests with lossless native command preservation --- crates/engine/src/cmd/actions_cmds/tests.rs | 12 ++++++-- .../src/cmd/atn_cmds/layer_props_tests.rs | 10 +++++-- .../engine/src/cmd/atn_cmds/stylize_tests.rs | 10 +++++-- crates/engine/tests/atn_mapped_identifiers.rs | 16 ++++++---- crates/engine/tests/content_aware_fill_atn.rs | 5 +++- crates/engine/tests/convert_profile_atn.rs | 9 ++++-- .../tests/it/atn_crystallize_cell_size.rs | 12 ++++---- .../engine/tests/it/atn_direct_adjustments.rs | 6 ++-- crates/engine/tests/it/atn_newer_events.rs | 28 ++++++++--------- crates/engine/tests/it/atn_ripple_amount.rs | 12 ++++---- crates/engine/tests/it/atn_selective_color.rs | 2 +- crates/engine/tests/it/atn_stroke_width.rs | 4 +-- crates/engine/tests/it/main.rs | 3 ++ crates/engine/tests/paste_style_options.rs | 5 +++- crates/engine/tests/support/atn_export.rs | 30 +++++++++++++++++++ crates/engine/tests/vector_paste_options.rs | 5 +++- 16 files changed, 119 insertions(+), 50 deletions(-) create mode 100644 crates/engine/tests/support/atn_export.rs diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 02cbd051e90..dda3ebccbcb 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -762,8 +762,8 @@ fn recorded_layer_drag_replays_on_another_document() { } /// An Alt-drag copy records `inPlace` (#7200), which only matters for an artboard: a plain -/// layer's recorded copy must still export to .atn, while an artboard's must be refused rather -/// than exported as a copy beside the original. +/// layer's recorded copy has a Photoshop event, while an artboard's uses the native +/// carrier rather than exporting as a copy beside the original. #[test] fn recorded_in_place_duplicate_keeps_in_place_only_for_artboards() { let mut s = Session::new(); @@ -778,7 +778,13 @@ fn recorded_in_place_duplicate_keeps_in_place_only_for_artboards() { assert_eq!(s.actions.list[0].steps, vec![("layer.duplicate".to_string(), json!({}))]); assert_eq!(s.actions.list[1].steps, vec![("layer.duplicate".to_string(), json!({"inPlace": true}))]); assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[0]], &[], false).is_ok()); - assert!(crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], false).is_err()); + let (bytes, skipped) = crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = photocraft_psd::atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items[0].event, photocraft_psd::atn::AtnEvent::name("photocraftCommand")); + assert_eq!(crate::atn_cmds::import(&bytes).unwrap().actions[0].steps, s.actions.list[1].steps); + let (_, skipped) = crate::atn_cmds::export("Audit", &[&s.actions.list[1]], &[], true).unwrap(); + assert_eq!(skipped.len(), 1); // Several selected layers are copied together: one artboard among them keeps `inPlace`. let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); diff --git a/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs b/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs index e4ebcd0ea0a..037d57c3288 100644 --- a/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs @@ -59,7 +59,7 @@ fn expanded_properties_keep_flags_and_original_skip_indices() { } #[test] -fn invalid_compound_properties_fail_or_skip_the_whole_source_step() { +fn unrepresentable_compound_properties_stay_native_or_skip_the_whole_source_step() { let action = Action::new( "Atomic", vec![ @@ -68,8 +68,12 @@ fn invalid_compound_properties_fail_or_skip_the_whole_source_step() { ("select.inverse".into(), json!({})), ], ); - let error = export("Set", &[&action], &[], false).unwrap_err(); - assert!(error.contains("step 2"), "{error}"); + let (bytes, skipped) = export("Set", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), 3, "the compound native step stays atomic"); + assert_eq!(file.actions[0].items[1].event, AtnEvent::name("photocraftCommand")); + assert_eq!(import(&bytes).unwrap().actions[0].steps, action.steps); let (bytes, skipped) = export("Set", &[&action], &[], true).unwrap(); assert_eq!(skipped, [Skipped { action: "Atomic".into(), step: 1, id: "layer.setProps".into() }]); let file = atn::parse(&bytes).unwrap(); diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 976940ef439..d880d99d9bd 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -309,7 +309,7 @@ fn enum_values_map_exactly_and_unknown_descriptors_stay_kept() { /// Native defaults stay native; seeds, frozen colors, targets and lossy values are not discarded. #[test] -fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { +fn exports_materialize_defaults_and_preserve_unrepresentable_native_settings() { for fixture in fixtures() { let item = export_item(fixture.id, &json!({}), "Group D").unwrap(); let mut expected = fixture.default.clone(); @@ -337,7 +337,13 @@ fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { params[key] = value; let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(fixture.id.into(), params)])); - assert!(s.execute("actions.save", json!({"action":"A"})).is_err(), "{}/{key}", fixture.id); + let saved = saved_items(&mut s); + assert_eq!(saved[0].event, AtnEvent::name("photocraftCommand"), "{}/{key}", fixture.id); + let (bytes, skipped) = export("S", &[&s.actions.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps); + let (_, skipped) = export("S", &[&s.actions.list[0]], &[], true).unwrap(); + assert_eq!(skipped.len(), 1, "{}/{key}", fixture.id); } let mut params = fixture.defaults.clone(); params["target"] = json!("pixels"); diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index df643b2608e..9d5dd799162 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -1,5 +1,8 @@ //! Recorded identifiers must match Photoshop, even when a local round trip would accept a typo. +#[path = "support/atn_export.rs"] +mod atn_export; + use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use photocraft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, @@ -453,7 +456,11 @@ fn native_units_validate_before_edit_and_no_resample_remains_resolution_only() { assert!(s.execute(id, params.clone()).is_err(), "{id} {params}"); assert_eq!(s.execute("document.inspect", json!({})).unwrap(), before); let action = Action::new("Invalid", vec![(id.into(), params)]); - assert_eq!(atn_cmds::export("Identifiers", &[&action], &[], false).is_err(), refuse_export); + if refuse_export { + crate::atn_export::fallback("Identifiers", &action); + } else { + assert!(atn_cmds::export("Identifiers", &[&action], &[], false).is_ok()); + } } let params = json!({"width":1e308,"units":"pt","resolution":144,"resample":"none"}); s.execute("image.imageSize", params.clone()).unwrap(); @@ -610,10 +617,7 @@ fn adjustment_opacity_validation_and_endpoints_are_atomic() { let before = s.execute("document.inspect", json!({})).unwrap(); assert!(s.execute("layer.newAdjustmentLayer.gradientMap", json!({"opacity":bad})).is_err()); assert_eq!(s.execute("document.inspect", json!({})).unwrap(), before); - assert!( - atn_cmds::export("Identifiers", &[&Action::new("Bad", vec![("layer.newAdjustmentLayer.gradientMap".into(), json!({"opacity":bad}))])], &[], false) - .is_err() - ); + crate::atn_export::fallback("Identifiers", &Action::new("Bad", vec![("layer.newAdjustmentLayer.gradientMap".into(), json!({"opacity":bad}))])); } for opacity in [None, Some(0.0), Some(1.0)] { let p = opacity.map_or_else(|| json!({}), |opacity| json!({"opacity":opacity})); @@ -785,5 +789,5 @@ fn document_offsets_wrap_in_opening_order_and_zero_atn_offsets_stay_kept() { let action = recorded(AtnEvent::Code(*b"slct"), zero.clone()); assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); assert_eq!(saved(&action).descriptor, Some(zero)); - assert!(atn_cmds::export("Identifiers", &[&Action::new("Zero", vec![("document.activate".into(), json!({"offset":0}))])], &[], false).is_err()); + crate::atn_export::fallback("Identifiers", &Action::new("Zero", vec![("document.activate".into(), json!({"offset":0}))])); } diff --git a/crates/engine/tests/content_aware_fill_atn.rs b/crates/engine/tests/content_aware_fill_atn.rs index 796982207f0..398683b5346 100644 --- a/crates/engine/tests/content_aware_fill_atn.rs +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -1,5 +1,8 @@ //! #10694: Content-Aware Fill options survive ATN load, save and playback. +#[path = "support/atn_export.rs"] +mod atn_export; + use photocraft_doc::{LayerId, Rect}; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use photocraft_psd::{ @@ -158,7 +161,7 @@ fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { let mut params = json!({"contents": "contentAware"}); params[param] = value; let action = Action::new("Invalid", vec![("edit.fill".into(), params)]); - assert!(atn_cmds::export("CAF", &[&action], &[], false).is_err()); + crate::atn_export::fallback("CAF", &action); } } } diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs index cf1f91b6dea..488cdeb491e 100644 --- a/crates/engine/tests/convert_profile_atn.rs +++ b/crates/engine/tests/convert_profile_atn.rs @@ -1,5 +1,8 @@ //! Recorded-shaped Convert to Profile flags survive ATN load, save and native playback. +#[path = "support/atn_export.rs"] +mod atn_export; + use std::sync::Arc; use photocraft_cms::Builtin; @@ -221,7 +224,7 @@ fn defaults_are_explicit_false_and_assign_profile_is_unchanged() { /// Unrepresented radio values and malformed booleans remain complete kept Photoshop steps. #[test] -fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() { +fn unsupported_values_are_preserved_and_invalid_native_values_use_native_events() { let mut descriptors = Vec::new(); for value in [D::Integer(3), D::Integer(4), D::Integer(2), D::Integer(999), D::Boolean(true), D::Double(2.0)] { descriptors.push(descriptor().with("sdwM", value)); @@ -246,11 +249,11 @@ fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() { let mut params = json!({"profile": "srgb"}); params[key] = value; let action = Action::new("Invalid", vec![("edit.convertToProfile".into(), params)]); - assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Profile", &action); } } let action = Action::new("Invalid radio null", vec![("edit.convertToProfile".into(), json!({"profile": "srgb", "sdwM": null}))]); - assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Profile", &action); } /// A step recorded by the reference application (`sdwM` -1, dither on) maps and saves back as recorded. diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs index 8a015f1497d..617339a5e61 100644 --- a/crates/engine/tests/it/atn_crystallize_cell_size.rs +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -106,9 +106,11 @@ fn integer_cell_sizes_and_defaults_preserve_playback() { fn skipping_fractional_cell_sizes_reports_action_and_step() { let action = Action::new("Mixed Crystallize", [5.0, 5.4, 10.0, 10.25].into_iter().map(|cell_size| (CRYSTALLIZE.into(), json!({"cellSize": cell_size}))).collect()); - let error = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap_err(); - assert!(error.contains("step 2 of `Mixed Crystallize`"), "{error}"); - assert!(error.contains(CRYSTALLIZE) && error.contains("skipUnsupported"), "{error}"); + let items = crate::atn_export::preserved("Crystallize", &action); + assert_eq!(items.len(), 4); + for index in [1, 3] { + assert_eq!(items[index].event, atn::AtnEvent::name("photocraftCommand")); + } let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); assert_eq!( skipped, @@ -130,7 +132,7 @@ fn skipping_fractional_cell_sizes_reports_action_and_step() { /// The plain event still cannot carry mask/channel targets or nonnumeric cell sizes. #[test] -fn non_pixel_targets_and_malformed_fields_are_still_refused() { +fn non_pixel_targets_and_malformed_fields_use_native_events_or_are_skipped() { for params in [ json!({"cellSize": 5, "target": "mask"}), json!({"cellSize": 5, "target": "quickMask"}), @@ -143,7 +145,7 @@ fn non_pixel_targets_and_malformed_fields_are_still_refused() { json!({"cellSize": 1e100}), ] { let action = Action::new("Unsupported Crystallize", vec![(CRYSTALLIZE.into(), params.clone())]); - assert!(atn_cmds::export("Crystallize", &[&action], &[], false).is_err(), "{params}"); + crate::atn_export::fallback("Crystallize", &action); let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], true).unwrap(); assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Crystallize".into(), step: 0, id: CRYSTALLIZE.into() }]); assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); diff --git a/crates/engine/tests/it/atn_direct_adjustments.rs b/crates/engine/tests/it/atn_direct_adjustments.rs index 8f59433b9e0..7328ada0d30 100644 --- a/crates/engine/tests/it/atn_direct_adjustments.rs +++ b/crates/engine/tests/it/atn_direct_adjustments.rs @@ -323,7 +323,7 @@ fn unsupported_gradient_settings_and_wire_forms_remain_kept() { /// Invalid recorded boxes stay preserved, and edited or unrepresentable native settings cannot save lossily. #[test] -fn invalid_replace_descriptors_and_lossy_exports_are_refused() { +fn invalid_replace_descriptors_stay_kept_and_lossy_exports_use_native_events() { let mut model = replace(); model.items[6].1 = D::Integer(1); let mut inverted = replace(); @@ -339,10 +339,10 @@ fn invalid_replace_descriptors_and_lossy_exports_are_refused() { } let mut s = load(*b"GrMp", gradient(), "image.adjustments.gradientMap"); s.actions.list[0].steps[0].1["stops"][0][1][0] = json!(0.2); - assert!(atn_cmds::export("Direct adjustments", &[&s.actions.list[0]], &[], false).is_err(), "stale color metadata must not fall through to GdMp"); + crate::atn_export::fallback("Direct adjustments", &s.actions.list[0]); for p in [json!({"color":"#ff0000"}), json!({"labMinimum":[40,0,0],"labMaximum":[60,0,0],"hue":0.5})] { let a = photocraft_engine::actions_cmds::Action::new("Native", vec![("image.adjustments.replaceColor".into(), p)]); - assert!(atn_cmds::export("Direct adjustments", &[&a], &[], false).is_err()); + crate::atn_export::fallback("Direct adjustments", &a); } } diff --git a/crates/engine/tests/it/atn_newer_events.rs b/crates/engine/tests/it/atn_newer_events.rs index acecf5ec7e2..bd4e0d549d2 100644 --- a/crates/engine/tests/it/atn_newer_events.rs +++ b/crates/engine/tests/it/atn_newer_events.rs @@ -208,7 +208,7 @@ fn atn_link_events_keep_descriptors_and_replay_the_requested_operation() { } #[test] -fn unsupported_link_descriptors_are_kept_and_ambiguous_exports_are_refused() { +fn unsupported_link_descriptors_are_kept_and_ambiguous_native_steps_are_preserved() { for event in ["linkSelectedLayers", "unlinkSelectedLayers"] { for descriptor in [ Descriptor::new("null").with("null", D::Reference(vec![])), @@ -229,7 +229,7 @@ fn unsupported_link_descriptors_are_kept_and_ambiguous_exports_are_refused() { // `{}` is the measured Link Layers toggle and exports (main's Group F mapping). for params in [json!({"linked": null}), json!({"linked": true, "layer": "Layer 0"}), json!({"linked": false, "layers": [1, 2]})] { let action = Action::new("A", vec![("layer.linkLayers".into(), params)]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Links", &action); } } @@ -309,7 +309,7 @@ fn bare_link_events_keep_their_operation_and_empty_keys() { } #[test] -fn target_events_keep_unknown_targets_and_refuse_native_options() { +fn target_events_keep_unknown_targets_and_preserve_native_options() { for &(event, id) in TARGET_EVENTS { for descriptor in [ Descriptor::new(event).with("null", D::Integer(1)), @@ -325,12 +325,12 @@ fn target_events_keep_unknown_targets_and_refuse_native_options() { } for p in [json!({"layer": 1}), json!({"on": true}), json!({"_atnNoTarget": false}), json!({"_atnNoTarget": "true"})] { let action = Action::new("A", vec![(id.into(), p)]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err(), "{event}"); + crate::atn_export::fallback("Links", &action); } } for linked in [false, true] { let action = Action::new("A", vec![("layer.linkLayers".into(), json!({"linked": linked, "_atnNoTarget": false}))]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Links", &action); } } @@ -590,7 +590,7 @@ fn artboard_fractional_colors_survive_import_save_and_playback() { assert_eq!(saved_item(&mut s).descriptor, Some(d)); let (id, mut changed) = s.actions.list[0].steps[0].clone(); changed["color"][0] = 0.5.into(); - assert!(atn_cmds::export("Links", &[&Action::new("Changed", vec![(id, changed)])], &[], false).is_err()); + crate::atn_export::fallback("Links", &Action::new("Changed", vec![(id, changed)])); let before = s.active().unwrap().doc.clone(); play(&mut s); let pixel = photocraft_compose::render(&s.active().unwrap().doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0]; @@ -625,7 +625,7 @@ fn artboard_unmeasured_types_and_extra_settings_stay_whole() { } for p in [json!({"layer":1}), json!({"preset":7}), json!({"guideIDs":[7.5]}), json!({"changeBackground":false})] { let action = Action::new("A", vec![("layer.new.artboardFromGroup".into(), p)]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Links", &action); } } @@ -640,7 +640,7 @@ fn artboard_omitted_type_defaults_to_white_and_invalid_native_settings_are_atomi assert_eq!(saved_item(&mut s).descriptor, Some(d)); let mut changed = s.actions.list[0].steps[0].1.clone(); changed["background"] = "black".into(); - assert!(atn_cmds::export("Links", &[&Action::new("A", vec![("layer.new.artboardFromGroup".into(), changed)])], &[], false).is_err()); + crate::atn_export::fallback("Links", &Action::new("A", vec![("layer.new.artboardFromGroup".into(), changed)])); play(&mut s); assert_eq!(photocraft_compose::render(&s.active().unwrap().doc, photocraft_doc::Rect::new(3, 3, 4, 4)).px[0], [1.0; 4]); assert!(s.undo()); @@ -709,7 +709,7 @@ fn artboard_background_edit_roundtrips_without_replaying_recorded_geometry() { /// Unmeasured geometry modes remain whole; edited native resize options cannot be silently omitted. #[test] -fn artboard_edit_refuses_unmeasured_modes_and_lossy_native_exports() { +fn artboard_edit_keeps_unmeasured_modes_and_preserves_native_export_settings() { let mut s = artboard_fixture(); for d in [artboard_edit_descriptor(1, 4), artboard_edit_descriptor(5, 8), artboard_edit_descriptor(1, 8).with("extra", D::Integer(1))] { let before = s.active().unwrap().doc.clone(); @@ -726,7 +726,7 @@ fn artboard_edit_refuses_unmeasured_modes_and_lossy_native_exports() { for (key, value) in [("rect", json!([0, 0, 100, 100])), ("preset", json!("iPhone 14")), ("changeSizes", json!(4)), ("changeBackground", json!(0))] { let mut edited = params.clone(); edited[key] = value; - assert!(atn_cmds::export("Links", &[&Action::new("A", vec![("layer.artboard.set".into(), edited)])], &[], false).is_err()); + crate::atn_export::fallback("Links", &Action::new("A", vec![("layer.artboard.set".into(), edited)])); } } @@ -954,7 +954,7 @@ fn make_type_shape_keeps_wrong_references_and_preserves_fill_mappings() { assert_eq!(s.actions.list[0].steps[0].0, id); } let action = Action::new("A", vec![("type.convertToShape".into(), json!({"layer": 1}))]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Links", &action); } fn radius_descriptor(radius: f64) -> Descriptor { @@ -1027,7 +1027,7 @@ fn shape_blur_keeps_custom_assets_and_invalid_radius_forms_whole() { } for p in [json!({"radius": 0}), json!({"radius": 1001}), json!({"radius": null}), json!({"radius": 10, "shape": "circle"}), json!({"target": "mask"})] { let action = Action::new("A", vec![("filter.blur.shapeBlur".into(), p)]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Links", &action); } } @@ -1180,11 +1180,11 @@ fn smart_paths_keep_missing_unusable_and_unrepresented_descriptors_whole() { } #[test] -fn smart_path_exports_refuse_unrepresentable_native_params() { +fn smart_path_exports_preserve_native_params_without_photoshop_substitution() { for &(_, id) in SMART_PATHS { for params in [json!({}), json!({"path": ""}), json!({"path": "a\0b"}), json!({"path": 3}), json!({"path": "/tmp/a.png", "layer": 1})] { let action = Action::new("A", vec![(id.into(), params)]); - assert!(atn_cmds::export("Links", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Links", &action); } } } diff --git a/crates/engine/tests/it/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs index 0ff38f2036c..0a88c275954 100644 --- a/crates/engine/tests/it/atn_ripple_amount.rs +++ b/crates/engine/tests/it/atn_ripple_amount.rs @@ -111,9 +111,11 @@ fn integer_amounts_sizes_and_defaults_preserve_playback() { fn skipping_fractional_amounts_reports_action_and_step() { let action = Action::new("Mixed Ripple", [10.0, 81.4, -20.0, -0.5].into_iter().map(|amount| (RIPPLE.into(), json!({"amount": amount, "size": "small"}))).collect()); - let error = atn_cmds::export("Ripple", &[&action], &[], false).unwrap_err(); - assert!(error.contains("step 2 of `Mixed Ripple`"), "{error}"); - assert!(error.contains(RIPPLE) && error.contains("skipUnsupported"), "{error}"); + let items = crate::atn_export::preserved("Ripple", &action); + assert_eq!(items.len(), 4); + for index in [1, 3] { + assert_eq!(items[index].event, atn::AtnEvent::name("photocraftCommand")); + } let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], true).unwrap(); assert_eq!( skipped, @@ -136,7 +138,7 @@ fn skipping_fractional_amounts_reports_action_and_step() { /// A dedicated Ripple mapping must retain the plain-event target restriction. #[test] -fn non_pixel_targets_and_malformed_fields_are_still_refused() { +fn non_pixel_targets_and_malformed_fields_use_native_events_or_are_skipped() { for params in [ json!({"amount": 81, "target": "mask"}), json!({"amount": 81, "target": "quickMask"}), @@ -150,7 +152,7 @@ fn non_pixel_targets_and_malformed_fields_are_still_refused() { json!({"amount": 81, "size": "unknown"}), ] { let action = Action::new("Unsupported Ripple", vec![(RIPPLE.into(), params.clone())]); - assert!(atn_cmds::export("Ripple", &[&action], &[], false).is_err(), "{params}"); + crate::atn_export::fallback("Ripple", &action); let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], true).unwrap(); assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Unsupported Ripple".into(), step: 0, id: RIPPLE.into() }]); assert!(atn_cmds::import(&bytes).unwrap().actions[0].steps.is_empty()); diff --git a/crates/engine/tests/it/atn_selective_color.rs b/crates/engine/tests/it/atn_selective_color.rs index 92a23aff5e6..394126a1084 100644 --- a/crates/engine/tests/it/atn_selective_color.rs +++ b/crates/engine/tests/it/atn_selective_color.rs @@ -139,7 +139,7 @@ fn malformed_selective_color_params_cannot_be_silently_exported() { ] { for id in COMMANDS { let action = Action::new("Invalid", vec![(id.into(), params.clone())]); - assert!(atn_cmds::export("Regression", &[&action], &[], false).is_err(), "strict export accepted {id} {params}"); + crate::atn_export::fallback("Regression", &action); let (_, skipped) = atn_cmds::export("Regression", &[&action], &[], true).unwrap(); assert_eq!(skipped.len(), 1, "skipUnsupported must report {id} {params}"); } diff --git a/crates/engine/tests/it/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs index 3034fb2f0a4..a64bd771971 100644 --- a/crates/engine/tests/it/atn_stroke_width.rs +++ b/crates/engine/tests/it/atn_stroke_width.rs @@ -156,10 +156,10 @@ fn descriptor_width_matches_defaults_limits_and_other_stroke_options() { /// A custom Stroke converter must retain the plain-event mask/channel target guard. #[test] -fn stroke_mask_and_channel_targets_are_still_refused_and_reported() { +fn stroke_mask_and_channel_targets_are_preserved_or_skipped_and_reported() { for target in [json!("mask"), json!("quickMask"), json!({"channel": 0})] { let action = Action::new("Targeted", vec![("edit.stroke".into(), json!({"width": 2.4, "target": target}))]); - assert!(atn_cmds::export("Widths", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Widths", &action); let (bytes, skipped) = atn_cmds::export("Widths", &[&action], &[], true).unwrap(); assert_eq!(skipped, vec![atn_cmds::Skipped { action: "Targeted".into(), step: 0, id: "edit.stroke".into() }]); assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index c02c4061ac9..bde01cbca12 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,5 +1,8 @@ //! The crate's integration tests, linked into one test binary. +#[path = "../support/atn_export.rs"] +mod atn_export; + mod action_metadata; mod actions_duplicate; mod actions_editor_draft; diff --git a/crates/engine/tests/paste_style_options.rs b/crates/engine/tests/paste_style_options.rs index af0f131dd8b..fbbcccb4f46 100644 --- a/crates/engine/tests/paste_style_options.rs +++ b/crates/engine/tests/paste_style_options.rs @@ -1,5 +1,8 @@ //! Paste Layer Style applies group eligibility consistently and preserves the ATN flag. +#[path = "support/atn_export.rs"] +mod atn_export; + use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{BlendRange, Document, Effect, Layer, LayerId}; use photocraft_engine::{Session, actions_cmds::Action, atn_cmds, layer_multi_cmds}; @@ -261,6 +264,6 @@ fn atn_defaults_are_explicit_and_unsupported_fields_remain_whole_steps() { let mut params = json!({}); params[key] = value; let action = Action::new("Invalid", vec![(CMD.into(), params)]); - assert!(atn_cmds::export("Style", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Style", &action); } } diff --git a/crates/engine/tests/support/atn_export.rs b/crates/engine/tests/support/atn_export.rs new file mode 100644 index 00000000000..55c834444ae --- /dev/null +++ b/crates/engine/tests/support/atn_export.rs @@ -0,0 +1,30 @@ +//! Assertions for the shared ATN library's native fallback and opt-in Photoshop omission. +use photocraft_engine::{actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnItem}, + descriptor::Value as D, +}; + +pub fn preserved(set: &str, action: &Action) -> Vec { + let (bytes, skipped) = atn_cmds::export(set, &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let loaded = atn_cmds::import(&bytes).unwrap(); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps, action.steps); + atn::parse(&bytes).unwrap().actions[0].items.clone() +} + +/// A one-step command cannot be emitted as a lossy Photoshop approximation. +pub fn fallback(set: &str, action: &Action) { + assert_eq!(action.steps.len(), 1); + let items = preserved(set, action); + assert_eq!(items.len(), 1); + assert_eq!(items[0].event, atn::AtnEvent::name("photocraftCommand")); + let descriptor = items[0].descriptor.as_ref().unwrap(); + let Some(D::Text(command)) = descriptor.get("command") else { panic!("missing command TEXT") }; + let Some(D::Text(params)) = descriptor.get("params") else { panic!("missing params TEXT") }; + assert_eq!(command.to_string_lossy(), action.steps[0].0); + assert_eq!(serde_json::from_str::(¶ms.to_string_lossy()).unwrap(), action.steps[0].1); + let (_, skipped) = atn_cmds::export(set, &[action], &[], true).unwrap(); + assert_eq!(skipped, [atn_cmds::Skipped { action: action.name.clone(), step: 0, id: action.steps[0].0.clone() }]); +} diff --git a/crates/engine/tests/vector_paste_options.rs b/crates/engine/tests/vector_paste_options.rs index 0482a5ab999..319d64ad7fd 100644 --- a/crates/engine/tests/vector_paste_options.rs +++ b/crates/engine/tests/vector_paste_options.rs @@ -1,5 +1,8 @@ //! Explicit Paste modes preserve source kind instead of silently using the ordinary raster route. +#[path = "support/atn_export.rs"] +mod atn_export; + use photocraft_engine::Session; use serde_json::json; @@ -335,7 +338,7 @@ fn atn_unknown_forms_are_kept_and_legacy_omission_stays_legacy() { json!({"as":"pixels","inPlace":false}), ] { let action = Action::new("Bad", vec![("edit.paste".into(), p)]); - assert!(atn_cmds::export("Vector", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Vector", &action); } } From d35d966bed91f7cdbea9339896ce718101f6cedf Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:36:06 -0800 Subject: [PATCH 093/384] Preserve small transform handles and projected edge points --- .../src/tools/transform_tool/pointer.rs | 35 +++++++---- .../ui-egui/src/transform_hit_zone_tests.rs | 60 ++++++++++++++++++- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 781e02ec207..735bec06587 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -23,19 +23,32 @@ impl TransformSession { // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. let corner_radius = tol * (8.0 / HANDLE_PX); let edge_width = tol * (11.0 / HANDLE_PX); + // Canvas coordinates round through f32. Treat only sub-pixel projection noise as + // the line itself; a point one screen pixel inside still belongs to the interior. + let line_epsilon = tol.abs() * (1e-3 / HANDLE_PX); let distance = |q: [f64; 2]| (p[0] - q[0]).hypot(p[1] - q[1]); - if let Some((i, _)) = quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) { - return Hit::Corner(i); - } let interior = inside(quad, p); - let edge = (0..4) - .filter_map(|i| { - let (_, q) = edge_projection(quad, i, p)?; - let d = distance(q); - // One pixel inside is interior, not a scale handle. The line itself is a handle. - (d <= edge_width && (!interior || d <= tol.abs() * 1e-9)).then_some((i, d)) - }) - .min_by(|a, b| a.1.total_cmp(&b.1)); + let edge_distance = |i| { + let (_, q) = edge_projection(quad, i, p)?; + let d = distance(q); + (d <= edge_width && (!interior || d <= line_epsilon)).then_some(d) + }; + if let Some((i, corner_distance)) = + quad.iter().enumerate().map(|(i, &q)| (i, distance(q))).filter(|(_, d)| *d <= corner_radius).min_by(|a, b| a.1.total_cmp(&b.1)) + { + // Corners beat edge bands, but overlapping painted handles on small boxes keep + // their nearest-handle priority. Otherwise a 16pt box's edge center is a corner. + let painted_edge = (0..4) + .filter_map(|k| { + edge_distance(k)?; + let (a, b) = (quad[k], quad[(k + 1) % 4]); + let d = distance([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]); + (d < corner_distance).then_some((k, d)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + return painted_edge.map_or(Hit::Corner(i), |(k, _)| Hit::Edge(k)); + } + let edge = (0..4).filter_map(|i| edge_distance(i).map(|d| (i, d))).min_by(|a, b| a.1.total_cmp(&b.1)); match edge { Some((i, _)) => Hit::Edge(i), None if interior => Hit::Inside, diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 66d13125b79..010d4e68e94 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -69,7 +69,65 @@ fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { assert_eq!(hit(&t, p.map(|v| v / zoom), 12.0 / zoom), expected, "zoom {zoom}, screen {p:?}"); } let tiny = session([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); - assert_eq!(TransformSession::box_hit(&tiny.quad, [5.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + assert_eq!(TransformSession::box_hit(&tiny.quad, [1.0, -1.0], 12.0), Hit::Corner(0), "corners beat overlapping edge bands"); + } +} + +#[test] +fn small_boxes_keep_their_painted_mid_edge_handles() { + for side in [8.0, 16.0, 32.0] { + for zoom in [1.0, 2.0] { + let q = [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]].map(|p| p.map(|v| v / zoom)); + let t = session(q); + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let midpoint = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + assert_eq!(hit(&t, midpoint, 12.0 / zoom), Hit::Edge(i), "side {side}, zoom {zoom}, edge {i}"); + assert_eq!(hit(&t, a, 12.0 / zoom), Hit::Corner(i)); + } + } + } +} + +#[test] +fn an_edge_survives_the_actual_f32_view_round_trip() { + for zoom in [1.0_f32, 2.0] { + let q = [[100.0, 100.00002], [500.0, 100.00002], [500.0, 300.0], [100.0, 300.0]].map(|p| p.map(|v| v / f64::from(zoom))); + let t = session(q); + let xf = view(zoom); + let p = xf.to_doc(xf.to_screen((180.0 / f64::from(zoom)) as f32, q[0][1] as f32)); + let inward_rounding = (p[1] - q[0][1]) * f64::from(zoom); + assert!(inward_rounding > 0.0 && inward_rounding < 1e-3, "fixture must expose inward f32 rounding: {inward_rounding}"); + assert_eq!(hit(&t, p, 12.0 / f64::from(zoom)), Hit::Edge(0)); + assert_eq!(hit(&t, [p[0], q[0][1] + 1.0 / f64::from(zoom)], 12.0 / f64::from(zoom)), Hit::Inside, "a whole pixel inside still moves"); + } +} + +#[test] +fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { + for mode in [TransformMode::Distort, TransformMode::Free] { + let mut app = app(QUAD, 1.0); + app.ui.transform = None; + app.ui.extras.snap = false; + app.ui.transform_unlinked = true; + app.session.edit_prefs(|p| p.general.use_legacy_free_transform = true); + app.session.execute("shape.create", serde_json::json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap(); + crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap(); + app.ui.transform.as_mut().unwrap().mode = mode; + let before = app.ui.transform.as_ref().unwrap().quad; + for event in [ + crate::canvas::ToolEvent::Down { x: 24.0, y: 16.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Move { x: 40.0, y: 16.0, pressure: 1.0 }, + crate::canvas::ToolEvent::Up { x: 40.0, y: 16.0 }, + ] { + crate::canvas::tool_event(&mut app, event, Modifiers::NONE); + } + let q = app.ui.transform.as_ref().unwrap().quad; + if mode == TransformMode::Distort { + assert_eq!(q, before, "Distort still ignores an edge press"); + } else { + assert!((q[2][0] - q[0][0] - 32.0).abs() < 1e-6 && (q[2][1] - q[0][1] - 16.0).abs() < 1e-6, "a legacy edge stretches one axis: {q:?}"); + } } } From a6eddde4aa6738b1728c6d88c56ccdb5ba9af4a4 Mon Sep 17 00:00:00 2001 From: NaufalDzz Date: Wed, 4 Nov 2026 11:36:55 +0700 Subject: [PATCH 094/384] =?UTF-8?q?Select=20=E2=80=BA=20Find=20Layers=20op?= =?UTF-8?q?ens=20the=20Layers=20panel's=20Name=20filter=20(#10884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As in Photoshop, Find Layers (Ctrl+Alt+Shift+F) no longer selects the layers whose name matches. From the menu or its shortcut it shows the Layers panel, switches the filter to Name and puts the caret in the Name field with its text selected, so typing replaces it. With a `name` param (scripts and agents) it sets the Name text instead. The layer selection is unchanged either way. select.findLayers no longer changes the selection and no longer fails when nothing matches; its reply lists the matching ids in `selected`, as before, and the Name filter applied in a new `layerFilter` field. Fixes #10884 --- crates/engine/src/cmd/layer_multi_cmds.rs | 10 +- .../src/cmd/layer_multi_cmds/selection.rs | 24 +++-- .../engine/src/cmd/layer_multi_cmds/tests.rs | 15 ++- crates/ui-egui/src/menus/invoke.rs | 2 + crates/ui-egui/src/panels/layer_filter_ui.rs | 8 +- .../src/panels/layer_filter_ui/find.rs | 44 +++++++++ .../src/panels/layer_filter_ui/find_tests.rs | 99 +++++++++++++++++++ docs/control-protocol.md | 1 + 8 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/find.rs create mode 100644 crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs diff --git a/crates/engine/src/cmd/layer_multi_cmds.rs b/crates/engine/src/cmd/layer_multi_cmds.rs index ada040e8b7e..dfbe283dfce 100644 --- a/crates/engine/src/cmd/layer_multi_cmds.rs +++ b/crates/engine/src/cmd/layer_multi_cmds.rs @@ -101,7 +101,15 @@ pub fn specs() -> Vec { } Ok(Value::Null) }), - spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers), + spec!( + "select.findLayers", + "Find Layers", + &["Select"], + Some("Cmd+Alt+Shift+F"), + r##"{"name":str?} (filters the Layers panel by Name, a case-insensitive substring, without changing the selection; replies with the matching layers)"##, + has_doc, + find_layers + ), spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)), spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")), spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")), diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index e5e361fb1ca..80422ce5520 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -334,16 +334,26 @@ pub(super) fn select_all_layers(s: &mut Session) -> Result { Ok(json!({"selected": n})) } +/// Select › Find Layers (#10884): as in Photoshop it filters the Layers panel by Name and leaves the +/// layer selection alone. The reply's `layerFilter` is the Name filter the app applies (`name` only +/// when one is given) and `selected` lists the layers it matches: part of the name, trimmed and +/// ignoring case, as the panel matches (none without a name). pub(super) fn find_layers(s: &mut Session, p: &Value) -> Result { - let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase(); + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad("select.findLayers", "`name` must be a string"))?), + }; let d = s.active().ok_or(EngineError::NoDocument)?; - let ids: Vec = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect(); - if ids.is_empty() { - return Err(EngineError::Other(format!("no layer name contains \"{needle}\""))); + let needle = name.map(|n| n.trim().to_lowercase()).filter(|n| !n.is_empty()); + let ids: Vec = match &needle { + Some(n) => d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(n.as_str())).map(|(_, _, l)| l.id.0).collect(), + None => Vec::new(), + }; + let mut filter = json!({"by": "name"}); + if let Some(n) = name { + filter["name"] = json!(n); } - let out = ids.iter().map(|l| l.0).collect::>(); - set_selection(s, ids, None, None)?; - Ok(json!({"selected": out})) + Ok(json!({"selected": ids, "layerFilter": filter})) } pub(super) fn select_linked(s: &mut Session) -> Result { diff --git a/crates/engine/src/cmd/layer_multi_cmds/tests.rs b/crates/engine/src/cmd/layer_multi_cmds/tests.rs index a3c055cf7b0..fbd3cd87cb7 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/tests.rs @@ -586,9 +586,18 @@ fn find_layers_by_name() { s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap(); s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap(); assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo"); - let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap(); - assert_eq!(r["selected"], json!([a.0, b.0])); - assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err()); + // #10884: Find Layers filters the Layers panel by Name and leaves the selection alone. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let r = s.execute("select.findLayers", json!({"name": " PHOTO "})).unwrap(); + assert_eq!(r["selected"], json!([a.0, b.0]), "the matching layers, trimmed and ignoring case"); + assert_eq!(r["layerFilter"], json!({"by": "name", "name": " PHOTO "}), "the app sets the Name text as given"); + assert_eq!(s.active().unwrap().selected_layers(), [a], "the selection is unchanged"); + let r = s.execute("select.findLayers", json!({"name": "nothing"})).unwrap(); + assert_eq!(r["selected"], json!([]), "no match lists nothing and is no error"); + let r = s.execute("select.findLayers", json!({})).unwrap(); + assert_eq!((&r["selected"], &r["layerFilter"]), (&json!([]), &json!({"by": "name"})), "without a name it only switches to Name"); + assert_eq!(s.active().unwrap().selected_layers(), [a]); + assert!(s.execute("select.findLayers", json!({"name": 3})).is_err()); assert!(s.execute("layer.renameLayer", json!({})).is_err()); } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index a089803be81..c995068197a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -414,6 +414,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let dialog = crate::channel_options_ui::open_new_spot(app).ok_or("no document is open")?; Ok(json!({"dialog": dialog})) } + // Select › Find Layers: the Layers panel's Name filter (#10884). + "select.findLayers" => crate::layer_filter_ui::find::invoke(app, ctx, params), // Photoshop's "Select and Mask…" is the engine's select.refineEdge. // Select › Color Range… from the menu: the dialog (with params: the engine directly). "select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})), diff --git a/crates/ui-egui/src/panels/layer_filter_ui.rs b/crates/ui-egui/src/panels/layer_filter_ui.rs index 0634d399189..ad3a741a4bb 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui.rs @@ -276,10 +276,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } - FilterBy::Name => { - let width = ui.available_width().max(48.0); - widgets::text_field(ui, ui.scope_id().with("layer-filter-name"), &mut app.ui.layer_filter_name, tl!("Search"), width, false); - } + FilterBy::Name => find::name_field(app, ui), FilterBy::Effect => effect::controls(app, ui), FilterBy::Mode => { let modes: Vec<(BlendMode, &str)> = std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect(); @@ -316,8 +313,11 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } pub mod effect; +pub mod find; #[cfg(test)] mod effect_tests; #[cfg(test)] +mod find_tests; +#[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find.rs b/crates/ui-egui/src/panels/layer_filter_ui/find.rs new file mode 100644 index 00000000000..964b76f0568 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find.rs @@ -0,0 +1,44 @@ +//! #10884: Select › Find Layers (⌥⇧⌘F) filters the Layers panel by Name, as in Photoshop: it shows +//! the Layers panel, switches the filter to Name and puts the caret in its field with the text +//! selected, so typing replaces it. The layer selection is not changed. With a `name` param +//! (scripts and agents) it sets the Name text to it instead of focusing the field. + +use serde_json::{Value, json}; + +use super::FilterBy; +use crate::{PhotocraftApp, widgets}; + +/// The egui memory flag: focus the Name field the next time it is drawn. +fn focus_flag() -> egui::Id { + egui::Id::unique("layer-filter-find-focus") +} + +/// Run `select.findLayers`: the engine checks `params` and lists the layers the Name text matches; +/// then the Name filter is applied. Without a `name` the text is kept, and the Layers panel is +/// shown with the field focused. +pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Result { + let given = params.get("name").is_some_and(|n| !n.is_null()); + let params = if given { params } else { json!({"name": app.ui.layer_filter_name}) }; + let reply = app.run("select.findLayers", params)?; + app.ui.layer_filter_by = FilterBy::Name; + if let Some(name) = reply["layerFilter"]["name"].as_str() { + app.ui.layer_filter_name = name.to_string(); + } + if !given { + *crate::dock::Group::Layers.tab_mut(&mut app.ui.dock_tabs) = 0; + crate::dock::reveal(app, crate::dock::Group::Layers); + ctx.data_mut(|d| d.insert_temp(focus_flag(), true)); + } + Ok(reply) +} + +/// The Name filter's text field, taking the focus with its text selected after [`invoke`]. +pub fn name_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let id = ui.scope_id().with("layer-filter-name"); + if ui.ctx().data_mut(|d| d.remove_temp::(focus_flag())).unwrap_or(false) { + ui.memory_mut(|m| m.request_focus(id)); + widgets::field_tab::select_all(ui.ctx(), id, &app.ui.layer_filter_name); + } + let width = ui.available_width().max(48.0); + widgets::text_field(ui, id, &mut app.ui.layer_filter_name, tl!("Search"), width, false); +} diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs new file mode 100644 index 00000000000..ea1dccd5ab1 --- /dev/null +++ b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs @@ -0,0 +1,99 @@ +//! #10884: Select › Find Layers opens the Layers panel's Name filter, against the cases measured in +//! Photoshop. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::LayerId; +use serde_json::json; + +use super::{Filter, FilterBy}; +use crate::PhotocraftApp; + +/// "LinkA", "Other" and "LinkB" over the Background, "Other" selected. +fn harness() -> Harness<'static, PhotocraftApp> { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + for name in ["LinkA", "Other", "LinkB"] { + s.execute("layer.new.layer", json!({"name": name})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let other = id(&h, "Other"); + h.state_mut().session.execute("layer.select", json!({"layer": other.0})).unwrap(); + h.run_steps(4); + h +} + +fn id(h: &Harness<'_, PhotocraftApp>, name: &str) -> LayerId { + h.state() + .session + .active() + .unwrap() + .doc + .walk() + .into_iter() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| l.id) + .unwrap_or_else(|| panic!("no layer {name}")) +} + +fn selected(h: &Harness<'_, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().selected_layers() +} + +/// The names the panel's filter lists, top to bottom. +fn listed(h: &Harness<'_, PhotocraftApp>) -> Vec { + let f = Filter::of_app(h.state()); + let doc = &h.state().session.active().unwrap().doc; + crate::layer_tree_ui::display_rows(doc, f.active()).into_iter().filter(|(_, l)| f.shows(l)).map(|(_, l)| l.name.clone()).collect() +} + +fn find(h: &mut Harness<'_, PhotocraftApp>, params: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + let reply = crate::menus::invoke(h.state_mut(), &ctx, "select.findLayers", params).unwrap(); + h.run_steps(4); + reply +} + +#[test] +fn the_menu_item_shows_the_layers_panel_and_focuses_the_name_field() { + let mut h = harness(); + h.state_mut().ui.panels.layers = false; + h.state_mut().ui.layer_filter_name = "old".into(); + h.run_steps(4); + let before = selected(&h); + find(&mut h, json!({})); + assert!(h.state().ui.panels.layers, "the Layers panel is shown"); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); + // The existing text is selected, so typing replaces it. + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), before, "Find Layers does not change the layer selection"); +} + +#[test] +fn a_name_param_sets_the_filter_text_without_changing_the_selection() { + let mut h = harness(); + let before = selected(&h); + let reply = find(&mut h, json!({"name": "link"})); + assert_eq!((h.state().ui.layer_filter_by, h.state().ui.layer_filter_name.as_str()), (FilterBy::Name, "link")); + assert_eq!(reply["selected"], json!([id(&h, "LinkA").0, id(&h, "LinkB").0]), "the reply lists the matching layers"); + assert_eq!(listed(&h), ["LinkB", "LinkA"]); + assert_eq!(selected(&h), before); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "a script does not take the keyboard focus"); +} + +#[test] +fn the_shortcut_runs_find_layers() { + let mut h = harness(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4a3c55d6ec9..a4e8d46bb56 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -55,6 +55,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `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=` / `--stroke-trace `): 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?, effect?, mode?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `color`, `smartObject` or `artboard` (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; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is a blend mode name (`Multiply`, `Pass Through`); `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode and Color always filter, by Bevel & Emboss, by Normal and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing. +- `select.findLayers` (#10884, Select › Find Layers) filters the Layers panel by Name and does not change the layer selection. With `name` it sets the Name text; without it the text is kept, the Layers panel is shown and its Name field takes the keyboard focus with the text selected. The reply lists the matching layer ids in `selected` and the Name filter applied in `layerFilter` (`{by: "name", name?}`). - `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown. - 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. - Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. From d72aa36d1d17bb4c113e77ba0cbc3e7014222909 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:41:06 -0800 Subject: [PATCH 095/384] Preserve Actions targets and reject incomplete definition publication --- crates/engine/src/cmd/actions_cmds.rs | 37 ++++-- crates/engine/src/cmd/actions_cmds/editor.rs | 45 ++++++-- crates/engine/src/cmd/actions_cmds/library.rs | 47 ++++++-- .../src/cmd/actions_cmds/library_atn_tests.rs | 108 ++++++++++++++++++ crates/engine/src/cmd/actions_edit_cmds.rs | 4 +- crates/engine/src/cmd/atn_cmds/sidecar.rs | 106 ++++++++++------- crates/engine/tests/it/actions_duplicate.rs | 63 ++++++++++ .../engine/tests/it/actions_editor_draft.rs | 97 +++++++++++++++- .../engine/tests/it/actions_group_delete.rs | 37 ++++++ 9 files changed, 474 insertions(+), 70 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 6094fb2fa81..662bf128f61 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -916,10 +916,16 @@ fn delete(s: &mut Session, p: &Value) -> Result { fn remove_actions(s: &mut Session, removed: &[usize]) -> Vec { let old_len = s.actions.list.len(); let names: Vec<_> = removed.iter().filter_map(|&index| s.actions.list.get(index).map(|action| action.name.clone())).collect(); + let mut vanished: BTreeSet<&str> = names.iter().map(String::as_str).collect(); + for (index, action) in s.actions.list.iter().enumerate() { + if removed.binary_search(&index).is_err() { + vanished.remove(action.name.as_str()); + } + } for (id, params) in s.actions.list.iter_mut().flat_map(|action| &mut action.steps) { for &key in call_keys(id) { let Some(value) = params.get(key) else { continue }; - if value.as_str().is_some_and(|name| names.iter().any(|deleted| deleted == name)) { + if value.as_str().is_some_and(|name| vanished.contains(name)) { params[key] = Value::Null; } else if let Some(index) = num(value).and_then(|index| usize::try_from(index).ok()).filter(|&index| index < old_len) { match removed.binary_search(&index) { @@ -936,7 +942,7 @@ fn remove_actions(s: &mut Session, removed: &[usize]) -> Vec { index += 1; keep }); - let keys: Vec<_> = names.iter().map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect(); + let keys: Vec<_> = vanished.iter().map(|name| format!("{PLAY_SHORTCUT_PREFIX}{name}")).collect(); if keys.iter().any(|key| s.prefs().shortcuts.contains_key(key) || s.prefs().alternate_shortcuts.contains_key(key)) { s.edit_prefs(|prefs| { for key in &keys { @@ -999,6 +1005,11 @@ fn call_keys(id: &str) -> &'static [&'static str] { } } +/// Named calls and shortcuts resolve to the first matching occurrence. +fn owns_name(list: &[Action], index: usize) -> bool { + list.get(index).is_some_and(|action| list.iter().position(|candidate| candidate.name == action.name) == Some(index)) +} + /// Rename an action in place. Nested `actions.play` steps and `actions.conditional` branches /// that call it by name, and its function-key binding, follow the new name so nothing silently /// stops working. @@ -1018,12 +1029,15 @@ fn rename(s: &mut Session, p: &Value) -> Result { if s.actions.list.iter().any(|a| a.name == name) { return Err(bad("actions.rename", "an action with this name already exists")); } - let pending = s.actions.recording_cursor().unwrap_or(s.journal.len()); - let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); - for (id, params) in calls { - for key in call_keys(id) { - if params.get(*key).and_then(Value::as_str) == Some(old.as_str()) { - params[*key] = json!(name); + let follows = owns_name(&s.actions.list, idx); + if follows { + let pending = s.actions.recording_cursor().unwrap_or(s.journal.len()); + let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending)); + for (id, params) in calls { + for key in call_keys(id) { + if params.get(*key).and_then(Value::as_str) == Some(old.as_str()) { + params[*key] = json!(name); + } } } } @@ -1032,10 +1046,13 @@ fn rename(s: &mut Session, p: &Value) -> Result { } s.actions.touch(); let (from, to) = (format!("{PLAY_SHORTCUT_PREFIX}{old}"), format!("{PLAY_SHORTCUT_PREFIX}{name}")); - if s.prefs().shortcuts.contains_key(&from) { + if follows && (s.prefs().shortcuts.contains_key(&from) || s.prefs().alternate_shortcuts.contains_key(&from)) { s.edit_prefs(|prefs| { if let Some(key) = prefs.shortcuts.remove(&from) { - prefs.shortcuts.insert(to, key); + prefs.shortcuts.insert(to.clone(), key); + } + if let Some(key) = prefs.alternate_shortcuts.remove(&from) { + prefs.alternate_shortcuts.insert(to, key); } }); } diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index 972a439be68..d6f91fe30c6 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -189,6 +189,15 @@ fn update(s: &mut Session, p: &Value) -> Result { let mut next = draft.clone(); if let Some(value) = p.get("name") { next.value.name = name(value, Some(&next.value.name), UPDATE)?; + if next.value.name != draft.value.name + && next + .value + .steps + .iter() + .any(|(id, params)| super::call_keys(id).iter().any(|key| params.get(key).and_then(Value::as_str) == Some(next.value.name.as_str()))) + { + return Err(bad(UPDATE, "an action cannot play itself")); + } } if let Some(value) = p.get("description") { next.value.description = value.as_str().ok_or_else(|| bad(UPDATE, "`description` must be a string"))?.to_string(); @@ -223,7 +232,7 @@ fn index(p: &Value, key: &str, len: usize, insert: bool) -> Result { } /// Validate newly authored content, without interpreting or normalizing an untouched opaque step. -fn new_step(p: &Value, old: Option<&(String, Value)>, draft: &Draft) -> Result<(String, Value)> { +fn new_step(s: &Session, p: &Value, old: Option<&(String, Value)>, draft: &Draft) -> Result<(String, Value)> { let id = match p.get("id") { Some(Value::String(id)) => id.as_str(), Some(_) => return Err(bad(STEP, "`id` must be a command string")), @@ -236,10 +245,13 @@ fn new_step(p: &Value, old: Option<&(String, Value)>, draft: &Draft) -> Result<( if !crate::actions_edit_cmds::is_step_command(id) || !params.is_object() { return Err(bad(STEP, "a new step needs a known command and object params")); } - if id == "actions.play" - && (params.get("action").and_then(Value::as_str) == Some(draft.value.name.as_str()) - || draft.source.is_some_and(|source| params.get("action").and_then(Value::as_u64) == Some(source as u64))) - { + let owns_current_name = draft.value.name != draft.opening.name || draft.source.is_none_or(|index| super::owns_name(&s.actions.list, index)); + if super::call_keys(id).iter().any(|key| { + owns_current_name && params.get(key).and_then(Value::as_str) == Some(draft.value.name.as_str()) + || draft.source.is_some_and(|index| super::owns_name(&s.actions.list, index)) + && params.get(key).and_then(Value::as_str) == Some(draft.opening.name.as_str()) + || draft.source.is_some_and(|source| params.get(key).and_then(Value::as_u64) == Some(source as u64)) + }) { return Err(bad(STEP, "an action cannot play itself")); } Ok((id.to_string(), params)) @@ -251,10 +263,10 @@ fn remap(value: &mut Action, old_len: usize, f: impl Fn(usize) -> Option) } /// Insert or replace after complete validation, retaining all unrelated Action fields. -fn insert_or_replace(value: &mut Action, draft: &Draft, p: &Value, op: &str) -> Result<()> { +fn insert_or_replace(s: &Session, value: &mut Action, draft: &Draft, p: &Value, op: &str) -> Result<()> { let len = value.steps.len(); let at = index(p, "step", len, op == "insert")?; - let step = new_step(p, if op == "replace" { value.steps.get(at) } else { None }, draft)?; + let step = new_step(s, p, if op == "replace" { value.steps.get(at) } else { None }, draft)?; if op == "insert" { if len >= MAX_STEPS { return Err(bad(STEP, "the draft exceeds the 100000-step limit")); @@ -319,7 +331,7 @@ fn step(s: &mut Session, p: &Value) -> Result { STEP, )?; match op { - "insert" | "replace" => insert_or_replace(&mut value, draft, p, op)?, + "insert" | "replace" => insert_or_replace(s, &mut value, draft, p, op)?, "delete" | "move" | "duplicate" => rearrange(&mut value, p, op)?, "enabled" | "dialog" => { let at = index(p, "step", value.steps.len(), false)?; @@ -411,7 +423,7 @@ fn rename_calls(params: &mut Value, id: &str, old: &str, new: &str) { /// Only changed references need prepared copies; no library or Session snapshot is taken. fn references(s: &Session, draft: &Draft, value: &mut Action) -> Vec<(usize, usize, Value)> { let mut changes = Vec::new(); - if draft.source.is_none() || draft.opening.name == value.name { + if !draft.source.is_some_and(|index| super::owns_name(&s.actions.list, index)) || draft.opening.name == value.name { return changes; } for (id, params) in &mut value.steps { @@ -435,7 +447,7 @@ fn bindings(s: &Session, draft: &Draft, value: &Action) -> Bindings { let mut alternates = s.prefs().alternate_shortcuts.clone(); let old = format!("{}{}", super::PLAY_SHORTCUT_PREFIX, draft.opening.name); let new = format!("{}{}", super::PLAY_SHORTCUT_PREFIX, value.name); - if draft.source.is_some() && old != new { + if draft.source.is_some_and(|index| super::owns_name(&s.actions.list, index)) && old != new { if let Some(key) = keys.remove(&old) { keys.insert(new.clone(), key); } @@ -460,6 +472,9 @@ fn bindings(s: &Session, draft: &Draft, value: &Action) -> Bindings { fn save(s: &mut Session, p: &Value) -> Result { fields(p, &["draft"], SAVE)?; not_playing(s, SAVE)?; + if s.has_recording_job() { + return Err(bad(SAVE, "finish the recorded step before saving the action")); + } let draft = owned(s, p, SAVE)?; if s.actions.recording.is_some() { return Err(bad(SAVE, "stop Classic recording first")); @@ -481,6 +496,16 @@ fn save(s: &mut Session, p: &Value) -> Result { return Err(bad(SAVE, "an action with this name already exists")); } let changes = references(s, draft, &mut value); + super::library::check_action_count( + &s.actions, + s.actions + .list + .iter() + .enumerate() + .map(|(index, action)| if Some(index) == draft.source { &value } else { action }) + .chain(draft.source.is_none().then_some(&value)), + ) + .map_err(|error| bad(SAVE, error))?; let (keys, alternates) = bindings(s, draft, &value); let target = draft.source.unwrap_or(s.actions.list.len()); let changed = draft.source.is_none() || value != draft.opening || !changes.is_empty(); diff --git a/crates/engine/src/cmd/actions_cmds/library.rs b/crates/engine/src/cmd/actions_cmds/library.rs index 1410da4d015..c8414698020 100644 --- a/crates/engine/src/cmd/actions_cmds/library.rs +++ b/crates/engine/src/cmd/actions_cmds/library.rs @@ -12,6 +12,21 @@ use crate::atn_cmds::{self, sidecar}; type Result = std::result::Result; const MAX_BYTES: usize = crate::preset_store::MAX_ACTIONS_BYTES as usize; +/// Check the encoded action-count ceiling before a definition edit is published. +/// The metadata carrier consumes one real ATN action even though it is hidden in the panel. +pub(crate) fn check_action_count<'a>(state: &ActionState, actions: impl IntoIterator) -> Result<()> { + let mut count = 0usize; + let mut carrier = state.library.sidecar.is_some() || state.library.legacy.is_some() || !state.atn_set_rows.is_empty(); + for action in actions { + count = count.checked_add(1).ok_or("Actions count overflow")?; + carrier |= sidecar::action_needs_metadata(action); + } + if count.checked_add(usize::from(carrier)).is_none_or(|total| total > atn::MAX_TOTAL_ACTIONS) { + return Err("actions archive leaves no space for the action and its metadata sidecar".into()); + } + Ok(()) +} + #[derive(Debug)] struct Archive { bytes: Arc<[u8]>, @@ -45,6 +60,7 @@ pub struct StepOrigin { pub(crate) struct RowOrigin { archive: Arc, set: usize, + logical_name: String, } impl RowOrigin { @@ -164,17 +180,35 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { state.library.sidecar = metadata.source; state.library.legacy = legacy; let mut total = 0usize; + let mut set_names = atn_cmds::UniqueNames::new( + std::iter::once(atn_cmds::UNGROUPED_SET.to_string()).chain( + archive + .parsed + .sets + .iter() + .enumerate() + .filter(|(index, _)| Some(*index) != archive.hidden) + .map(|(_, file)| file.name.to_string_lossy()) + .filter(|name| !name.is_empty()), + ), + ); + let literal_ungrouped = set_names.claim(atn_cmds::UNGROUPED_SET); for (set, file) in archive.parsed.sets.iter().enumerate().filter(|(index, _)| Some(*index) != archive.hidden) { let wire_name = file.name.to_string_lossy(); - let row = RowOrigin { archive: archive.clone(), set }; + let logical_name = if wire_name == atn_cmds::UNGROUPED_SET { + literal_ungrouped.as_str() + } else { + atn_cmds::logical_set_name((!wire_name.is_empty()).then_some(wire_name.as_str())) + }; + let row = RowOrigin { archive: archive.clone(), set, logical_name: logical_name.to_string() }; let slot = state.sets.len(); - state.sets.push(atn_cmds::logical_set_name((!wire_name.is_empty()).then_some(wire_name.as_str())).to_string()); + state.sets.push(logical_name.to_string()); state.library.rows.push(Some(row.clone())); if let Some(source) = metadata.sets.get_mut(set).and_then(Option::take) { state.atn_set_rows.push(sidecar::SetRowMetadata { slot, row: state.sets.last().cloned().ok_or("missing loaded row")?, source }); } for (index, original) in file.actions.iter().enumerate() { - let (mut action, cohorts) = atn_cmds::library_action(file, original, &wire_name)?; + let (mut action, cohorts) = atn_cmds::library_action(file, original, if wire_name.is_empty() { "" } else { logical_name })?; total = total.checked_add(action.steps.len()).filter(|count| *count <= atn::MAX_TOTAL_ITEMS).ok_or("too many native steps in Actions library")?; if let Some(metadata) = metadata.actions.get_mut(set).and_then(|actions| actions.get_mut(index)).and_then(Option::take) { sidecar::apply(&mut action, metadata); @@ -366,7 +400,7 @@ pub(crate) fn encode(state: &ActionState, keys: &[(String, String)]) -> Result Result Vec { + let files: Vec<_> = rows + .iter() + .map(|(name, actions)| { + let refs: Vec<_> = actions.iter().collect(); + let (bytes, skipped) = atn_cmds::export(name, &refs, &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse_sets(&bytes).unwrap().remove(0) + }) + .collect(); + atn::write_sets(&files).unwrap() +} + +#[test] +fn durable_regrouping_keeps_flat_call_targets_and_empty_rows() { + let first = Action::new("First", vec![]); + let second = Action::new("Second", vec![]); + let other = Action::new("Other", vec![]); + let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action":1}))]); + let bytes = ordered_archive(&[("Before", &[]), ("A", &[first, second]), ("Between", &[]), ("B", &[other]), ("C", &[caller])]); + let state = library::decode(&bytes).unwrap(); + assert_eq!(encode(&state), bytes, "untouched empty rows retain exact physical placement"); + let mut session = crate::Session::new(); + session.actions = state; + session.execute("actions.assignSet", json!({"action":0,"set":"B"})).unwrap(); + let restored = library::decode(&encode(&session.actions)).unwrap(); + assert_eq!(restored.list.iter().map(|action| action.name.as_str()).collect::>(), ["First", "Second", "Other", "Caller"]); + assert_eq!(restored.list[3].steps[0].1["action"], 1); + assert_eq!(restored.list[1].name, "Second"); + for name in ["Before", "Between"] { + assert_eq!(restored.sets.iter().filter(|row| row.as_str() == name).count(), 1); + } +} + +#[test] +fn duplicate_physical_rows_stay_after_all_original_targets() { + let first = Action::new("First", vec![]); + let second = Action::new("Second", vec![]); + let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action":1}))]); + let bytes = ordered_archive(&[("Group", &[first]), ("Empty", &[]), ("Group", &[second]), ("Other", &[caller])]); + let mut session = crate::Session::new(); + session.actions = library::decode(&bytes).unwrap(); + session.execute("actions.duplicate", json!({"set":"Group"})).unwrap(); + let restored = library::decode(&encode(&session.actions)).unwrap(); + assert_eq!(restored.list.iter().map(|action| action.name.as_str()).collect::>(), ["First", "Second", "First copy", "Second copy", "Caller"]); + assert_eq!(restored.list[4].steps[0].1["action"], 1); + assert_eq!(restored.list[1].name, "Second"); + assert_eq!(restored.sets.iter().filter(|name| name.as_str() == "Group").count(), 2); + assert_eq!(restored.sets.iter().filter(|name| name.as_str() == "Empty").count(), 1); +} + #[test] fn changing_one_name_preserves_every_other_original_field_and_occurrence() { let bytes = wire(); @@ -151,9 +203,47 @@ fn empty_library_has_a_known_carrier_and_no_invented_user_folder() { assert!(loaded.sets.is_empty()); assert_eq!(atn::parse_sets(&bytes).unwrap().len(), 1); assert_eq!(encode(&loaded), bytes); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.actions.is_empty()); + assert!(imported.sets.is_empty()); + // A foreign sidecar with no real set is still rejected; only the explicit native + // empty-library marker permits this otherwise ambiguous input through public import. + let mut files = atn::parse_sets(&bytes).unwrap(); + let outer = files[0].actions[0].items[0].descriptor.as_mut().unwrap(); + let (_, D::RawData(payload)) = outer.items.iter_mut().find(|(id, _)| id.is("modernizedActionData")).unwrap() else { panic!("native payload") }; + let mut root = Descriptor::from_bytes(&payload[4..]).unwrap(); + root.items.retain(|(id, _)| !id.is("photocraftEmptyLibraryV1")); + payload.truncate(4); + payload.extend(root.to_bytes()); + assert!(atn_cmds::import(&atn::write_sets(&files).unwrap()).is_err()); assert!(library::decode(&16u32.to_be_bytes()).is_err()); } +#[test] +fn literal_ungrouped_rows_remain_distinct_from_the_unnamed_category() { + let rows = [("", "Loose"), (atn_cmds::UNGROUPED_SET, "Named"), ("Ungrouped Actions 2", "Collision")]; + let files: Vec<_> = rows + .iter() + .map(|(set, action)| AtnFile { + name: UnicodeString::new_nul(set), + actions: vec![AtnAction { name: UnicodeString::new_nul(action), ..Default::default() }], + ..Default::default() + }) + .collect(); + let bytes = atn::write_sets(&files).unwrap(); + let state = library::decode(&bytes).unwrap(); + assert_eq!(state.sets, ["Ungrouped Actions", "Ungrouped Actions 3", "Ungrouped Actions 2"]); + assert_eq!(state.list[0].set, None); + assert_eq!(state.list[1].set.as_deref(), Some("Ungrouped Actions 3")); + assert_eq!(encode(&state), bytes, "logical disambiguation keeps untouched original wire names"); + let mut session = crate::Session::new(); + session.actions = state; + session.execute("actions.delete", json!({"set":"Ungrouped Actions"})).unwrap(); + assert_eq!(session.actions.list.iter().map(|action| action.name.as_str()).collect::>(), ["Named", "Collision"]); + let loaded = library::decode(&encode(&session.actions)).unwrap(); + assert_eq!(loaded.list.iter().map(|action| action.name.as_str()).collect::>(), ["Named", "Collision"]); +} + fn legacy() -> Vec { br#" {"version":1,"actions":[{"name":"Legacy","description":"kept","steps":[["future.command",{"x":1}]],"disabled":[0]}]} "#.to_vec() } @@ -177,6 +267,24 @@ fn migration_publishes_one_atn_and_renames_exact_json_bytes_once() { } } +#[test] +fn portable_exports_do_not_inherit_full_library_migration_receipts() { + let bytes = legacy(); + let mem = MemBackend::default(); + mem.files.lock().unwrap().insert(LEGACY_ACTIONS_FILE.into(), bytes.clone()); + assert!(preset_store::open(Box::new(mem.clone())).warnings.is_empty()); + let state = library::decode(&mem.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap()).unwrap(); + assert!(state.library.legacy.as_ref().unwrap().matches(&bytes)); + let (portable, skipped) = atn_cmds::export("Portable", &[&state.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert!(library::decode(&portable).unwrap().library.legacy.is_none()); + let unrelated_store = MemBackend::default(); + unrelated_store.files.lock().unwrap().extend([(ACTIONS_FILE.into(), portable), (LEGACY_ACTIONS_FILE.into(), bytes.clone())]); + let _ = preset_store::open(Box::new(unrelated_store.clone())); + assert_eq!(unrelated_store.read(LEGACY_ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + assert!(unrelated_store.read(MIGRATED_ACTIONS_FILE, MAX_ACTIONS_BYTES).is_err()); +} + #[test] fn corrupt_atn_has_precedence_and_corrupt_legacy_is_never_overwritten() { for (name, bytes) in [(ACTIONS_FILE, b"broken ATN".to_vec()), (LEGACY_ACTIONS_FILE, b"broken JSON".to_vec())] { diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index c0d506520c3..041af53e068 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -137,6 +137,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let mut copied = s.actions.list[idx].clone(); let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); copied.name = names.claim(&format!("{} copy", copied.name)); + crate::actions_cmds::library::check_action_count(&s.actions, s.actions.list.iter().chain(std::iter::once(&copied))).map_err(|error| bad(cmd, error))?; let at = idx + 1; let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); s.actions.list.insert(at, copied); @@ -162,7 +163,7 @@ fn duplicate_set(s: &mut Session, p: &Value) -> Result { let mut set_names = crate::atn_cmds::UniqueNames::new(sets); let name = set_names.claim(&format!("{source} copy")); let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone())); - let at = s.actions.list.iter().position(|a| crate::atn_cmds::logical_set_name(a.set.as_deref()) == source).map_or(s.actions.list.len(), |i| i + 1); + let at = s.actions.list.iter().rposition(|a| crate::atn_cmds::logical_set_name(a.set.as_deref()) == source).map_or(s.actions.list.len(), |i| i + 1); let added: Vec<_> = s .actions .list @@ -177,6 +178,7 @@ fn duplicate_set(s: &mut Session, p: &Value) -> Result { .collect(); let copied: Vec<_> = added.iter().map(|a| a.name.clone()).collect(); let count = added.len(); + crate::actions_cmds::library::check_action_count(&s.actions, s.actions.list.iter().chain(added.iter())).map_err(|error| bad(cmd, error))?; s.actions.list.splice(at..at, added); if count != 0 { shift_action_calls(&mut s.actions.list, at, count); diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs index 4401d6ed664..5ee23bb96ee 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -10,6 +10,7 @@ use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem}; use crate::actions_cmds::ActionState; const MODERN: &str = "modernizedActionData"; +const EMPTY_LIBRARY: &str = "photocraftEmptyLibraryV1"; const MAX_BYTES: usize = crate::preset_import_cmds::MAX_FILE_BYTES as usize; type Result = std::result::Result; @@ -250,7 +251,7 @@ pub(crate) fn install_row(state: &mut ActionState, name: &str, source: Option = state.sets.iter().enumerate().filter_map(|(slot, row)| (row == source).then_some(slot)).collect(); - let at = rows.first().map_or(state.sets.len(), |slot| slot + 1); + let at = rows.last().map_or(state.sets.len(), |slot| slot + 1); let mut copied: Vec<_> = rows.iter().map(|&slot| empty_source(state, slot)).collect(); if copied.is_empty() { copied.push(group_source(state, source)); @@ -334,43 +335,61 @@ pub(crate) fn selection<'a>(state: &ActionState, action: &'a Action) -> Selectio } pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result)>> { + groups_ordered(state, only, false) +} + +/// Durable libraries preserve flat resolution order, including interleaved logical sets. +pub(crate) fn library_groups(state: &ActionState) -> Result)>> { + groups_ordered(state, None, true) +} + +fn groups_ordered<'a>(state: &'a ActionState, only: Option<&str>, durable: bool) -> Result)>> { if state.list.len() > atn::MAX_TOTAL_ACTIONS || state.sets.len() > atn::MAX_SETS { return Err("action library exceeds archive limits".into()); } let raw_rows = state.library.row_index(&state.sets); - let mut names = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + let names: Vec<_> = state + .list + .iter() + .map(|action| super::logical_set_name(action.set.as_deref())) + .chain(state.sets.iter().map(String::as_str)) + .filter(|name| only.is_none_or(|only| only == *name) && seen.insert(*name)) + .collect(); + let sources: std::collections::HashMap<_, _> = names.iter().map(|&name| (name, group_source(state, name))).collect(); let mut out: Vec<(&str, Selection<'a>)> = Vec::new(); - for name in state.list.iter().map(|action| super::logical_set_name(action.set.as_deref())).chain(state.sets.iter().map(String::as_str)) { - if only.is_some_and(|only| only != name) || !names.insert(name) { - continue; - } - let fallback = group_source(state, name); - let before = out.len(); - for action in state.list.iter().filter(|action| super::logical_set_name(action.set.as_deref()) == name) { - let source = action.atn_metadata.as_ref().and_then(|metadata| metadata.set.clone()).or_else(|| fallback.clone()); - let raw = raw_rows.source(action); - let existing = out.last_mut().filter(|(previous, selection)| { - *previous == name - && selection.raw == raw - && match (&selection.source, &source) { - (None, None) => true, - (Some(left), Some(right)) => same_set(left, right), - _ => false, - } - }); - if let Some((_, selection)) = existing { - selection.actions.push(action); - } else { - if out.len() >= atn::MAX_SETS { - return Err("too many sets in actions archive".into()); + let ordered: Vec<_> = if durable { + state.list.iter().collect() + } else { + names.iter().flat_map(|name| state.list.iter().filter(move |action| super::logical_set_name(action.set.as_deref()) == *name)).collect() + }; + for action in ordered { + let name = super::logical_set_name(action.set.as_deref()); + let source = action.atn_metadata.as_ref().and_then(|metadata| metadata.set.clone()).or_else(|| sources.get(name).cloned().flatten()); + let raw = raw_rows.source(action); + let existing = out.last_mut().filter(|(previous, selection)| { + *previous == name + && selection.raw == raw + && match (&selection.source, &source) { + (None, None) => true, + (Some(left), Some(right)) => same_set(left, right), + _ => false, } - out.push((name, Selection { actions: vec![action], source, raw })); + }); + if let Some((_, selection)) = existing { + selection.actions.push(action); + } else { + if out.len() >= atn::MAX_SETS { + return Err("too many sets in actions archive".into()); } + out.push((name, Selection { actions: vec![action], source, raw })); } + } + for name in names { for (slot, _) in state.sets.iter().enumerate().filter(|(_, row)| row.as_str() == name) { let raw = state.library.rows.get(slot).cloned().flatten(); if let Some(raw) = raw.as_ref() - && !out.iter().skip(before).any(|(_, selection)| selection.raw.as_ref() == Some(raw)) + && !out.iter().any(|(current, selection)| *current == name && selection.raw.as_ref() == Some(raw)) { if out.len() >= atn::MAX_SETS { return Err("too many sets in actions archive".into()); @@ -378,7 +397,7 @@ pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result= atn::MAX_SETS { return Err("too many sets in actions archive".into()); @@ -386,15 +405,15 @@ pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result= atn::MAX_SETS { return Err("too many sets in actions archive".into()); } - out.push((name, Selection { actions: Vec::new(), source: fallback, raw: None })); + out.push((name, Selection { actions: Vec::new(), source: sources.get(name).cloned().flatten(), raw: None })); } } - // Preserve physical rows only when doing so preserves the panel's group/action order. - // An explicit move can change that projection, including S1/S2/S1 runs within one group. + // Restore physical rows only if the relevant order remains unchanged. Durable storage + // must preserve the entire flat list because numeric and first-name calls resolve there. let mut slots = std::collections::HashMap::new(); for row in &state.atn_set_rows { if state.sets.get(row.slot) == Some(&row.row) { @@ -414,7 +433,13 @@ pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result = physical.iter().flat_map(|&index| out[index].1.actions.iter().map(|action| std::ptr::from_ref(*action))).collect(); + projected == state.list.iter().map(std::ptr::from_ref).collect::>() + } else { + panel_projection(&out, 0..out.len()) == panel_projection(&out, physical.iter().copied()) + }; + if !preserves_order { return Ok(out); } Ok(physical @@ -535,7 +560,7 @@ fn decode_inner(files: &[AtnFile], library: bool, raw_outer: Option<&[u8]>) -> R return Err("unsupported Actions sidecar root".into()); } let sets = root.list("sets")?; - if sets.is_empty() && !library { + if sets.is_empty() && !library && unique(&parsed, EMPTY_LIBRARY)? != &D::Boolean(true) { return Err("Actions sidecar requires at least one real set".into()); } if sets.len().checked_add(1) != Some(files.len()) { @@ -689,6 +714,10 @@ pub(crate) fn append_library( append_inner(files, selected, force, source, receipt) } +pub(crate) fn action_needs_metadata(action: &Action) -> bool { + action.atn_metadata.is_some() || !action.description.is_empty() || !action.icon.is_empty() +} + fn append_inner( files: &mut Vec, selected: &[Selection<'_>], @@ -699,10 +728,7 @@ fn append_inner( if files.len() != selected.len() { return Err("sidecar export selection mismatch".into()); } - let needed = selected.iter().any(|selection| { - selection.source.is_some() - || selection.actions.iter().any(|action| action.atn_metadata.is_some() || !action.description.is_empty() || !action.icon.is_empty()) - }); + let needed = selected.iter().any(|selection| selection.source.is_some() || selection.actions.iter().any(|action| action_needs_metadata(action))); if !needed && !force { return Ok(None); } @@ -763,10 +789,12 @@ fn append_inner( Some(source) => source.root.patch(&[("sets", Some(objects(&sets)?))])?, None => RawDescriptor::new(&Descriptor::new("null").with("sets", D::List(Vec::new())).to_bytes())?.patch(&[("sets", Some(objects(&sets)?))])?, }; + let root = RawDescriptor::new(&root)?.patch(&[(EMPTY_LIBRARY, if files.is_empty() { Some(value_bytes(D::Boolean(true))?) } else { None })])?; let root = if let Some(receipt) = receipt { RawDescriptor::new(&root)?.patch(&[("photocraftLegacyJsonV1", Some(value_bytes(D::Descriptor(receipt.descriptor()?))?))])? } else { - root + // A portable subset is not proof that the complete legacy library was published. + RawDescriptor::new(&root)?.patch(&[("photocraftLegacyJsonV1", None)])? }; let mut payload = 16u32.to_be_bytes().to_vec(); extend(&mut payload, &root)?; diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs index 31907b7ceda..6ecc6632eb3 100644 --- a/crates/engine/tests/it/actions_duplicate.rs +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -2,6 +2,32 @@ use photocraft_engine::{Session, actions_cmds::Action}; use serde_json::{Value, json}; +#[test] +fn a_copied_group_keeps_numeric_call_identity_through_native_store_reload() { + use photocraft_engine::preset_store::{self, MemBackend}; + let store = MemBackend::default(); + let mut s = session(); + assert!(s.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + let mut first = Action::new("First", vec![("layer.new.layer".into(), json!({"name":"First marker"}))]); + first.set = Some("Group".into()); + let mut second = Action::new("Second", vec![("layer.new.layer".into(), json!({"name":"Second marker"}))]); + second.set = Some("Group".into()); + let mut caller = Action::new("Caller", vec![("actions.play".into(), json!({"action":1}))]); + caller.set = Some("Other".into()); + s.actions.list = vec![first, second, caller]; + s.execute("actions.duplicate", json!({"set":"Group"})).unwrap(); + assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["First", "Second", "First copy", "Second copy", "Caller"]); + assert_eq!(s.actions.list[4].steps[0].1["action"], 1); + let mut restored = Session::new(); + assert!(restored.attach_preset_store(preset_store::open(Box::new(store))).is_empty()); + assert_eq!(restored.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["First", "Second", "First copy", "Second copy", "Caller"]); + assert_eq!(restored.actions.list[4].steps[0].1["action"], 1); + restored.execute("file.new", json!({"width":8,"height":6})).unwrap(); + let result = restored.execute("actions.play", json!({"action":"Caller"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(restored.active().unwrap().doc.layers.last().unwrap().name, "Second marker"); +} + /// A tiny document to distinguish definition edits from document mutations and playback. fn session() -> Session { let mut s = Session::new(); @@ -9,6 +35,43 @@ fn session() -> Session { s } +#[test] +fn action_count_admission_reserves_the_metadata_carrier_before_publication() { + for metadata in [false, true] { + for operation in ["action", "set", "editor"] { + let mut s = Session::new(); + let capacity = photocraft_psd::atn::MAX_TOTAL_ACTIONS - usize::from(metadata); + s.actions.list = (0..capacity - 1).map(|index| Action::new(&format!("Action {index}"), vec![])).collect(); + s.actions.list[0].set = Some("Source".into()); + if metadata { + s.actions.list[0].description = "Requires the hidden metadata action".into(); + } + for admitted in [true, false] { + let (command, params) = match operation { + "action" => ("actions.duplicate", json!({"action":0})), + "set" => ("actions.duplicate", json!({"set":"Source"})), + _ => { + let draft = s.execute("actions.editor.open", json!({"name":format!("New {admitted}")})).unwrap()["draft"].as_u64().unwrap(); + s.execute("actions.editor.step", json!({"draft":draft,"op":"insert","step":0,"id":"select.all","params":{}})).unwrap(); + ("actions.editor.save", json!({"draft":draft})) + } + }; + let before = s.actions.clone(); + let result = s.execute(command, params.clone()); + assert_eq!(result.is_ok(), admitted, "{operation}, metadata={metadata}: {result:?}"); + if admitted { + assert_eq!(s.actions.list.len(), capacity); + if operation == "editor" { + s.execute("actions.editor.discard", json!({"draft":params["draft"]})).unwrap(); + } + } else { + assert_eq!(s.actions, before, "a full archive retains its list, rows and unpublished draft"); + } + } + } + } +} + /// Copies retain metadata and call targets without taking the original function key. #[test] fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() { diff --git a/crates/engine/tests/it/actions_editor_draft.rs b/crates/engine/tests/it/actions_editor_draft.rs index 3ff4b383f3e..0379fb6d4b9 100644 --- a/crates/engine/tests/it/actions_editor_draft.rs +++ b/crates/engine/tests/it/actions_editor_draft.rs @@ -39,6 +39,42 @@ fn open(s: &mut Session, params: Value) -> u64 { s.execute("actions.editor.open", params).unwrap()["draft"].as_u64().unwrap() } +#[test] +fn namesake_rename_follows_only_the_first_resolved_occurrence_in_both_editors() { + for modern in [false, true] { + for index in [0, 1] { + let (mut s, _) = session(); + s.actions.list.insert(1, s.actions.list[0].clone()); + s.actions.list[2].steps.push(("actions.conditional".into(), json!({"if":"always","then":"Original","else":"Other"}))); + s.prefs.edit(|prefs| { + prefs.alternate_shortcuts.insert("actions.play:Original".into(), "Shift+F8".into()); + }); + if modern { + let draft = open(&mut s, json!({"action":index})); + editor(&mut s, "update", draft, json!({"name":"Renamed"})).unwrap(); + // A later occurrence may legitimately call the first, including after rename. + if index == 1 { + editor(&mut s, "step", draft, json!({"op":"insert","step":1,"id":"actions.play","params":{"action":"Original"}})).unwrap(); + } + editor(&mut s, "save", draft, json!({})).unwrap(); + } else { + s.execute("actions.rename", json!({"action":index,"name":"Renamed"})).unwrap(); + } + let owner = if index == 0 { "Renamed" } else { "Original" }; + let caller = s.actions.list.iter().find(|action| action.name == "Caller").unwrap(); + assert_eq!(caller.steps[0].1["action"], owner, "modern={modern}, index={index}"); + assert_eq!(caller.steps[1].1["then"], owner); + assert_eq!(caller.steps[1].1["else"], "Other"); + let key = format!("actions.play:{owner}"); + assert_eq!(s.prefs().shortcuts.get(&key).map(String::as_str), Some("F6")); + assert_eq!(s.prefs().alternate_shortcuts.get(&key).map(String::as_str), Some("Shift+F8")); + let other = if index == 0 { "Original" } else { "Renamed" }; + assert!(!s.prefs().shortcuts.contains_key(&format!("actions.play:{other}"))); + assert!(!s.prefs().alternate_shortcuts.contains_key(&format!("actions.play:{other}"))); + } + } +} + /// Metadata, portable recorded targets and named calls stay out of the stored library. #[test] fn edits_and_recording_stay_draft_until_save_or_discard() { @@ -223,8 +259,9 @@ fn classic_recording_remains_immediate_without_editor_save() { let len = s.actions.list.len(); s.execute("actions.record", json!({"name":"Classic"})).unwrap(); assert_eq!(s.actions.list.len(), len + 1); - let native: Value = serde_json::from_slice(&store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap()).unwrap(); - assert_eq!(native["actions"].as_array().unwrap().len(), len + 1); + let mut restored = Session::new(); + assert!(restored.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + assert_eq!(restored.actions.list.len(), len + 1); assert!(s.execute("actions.editor.open", json!({})).is_err()); s.execute("edit.fill", json!({"color":"#00ff00"})).unwrap(); assert_eq!(photocraft_engine::actions_cmds::pending_steps(&s, len).count(), 1); @@ -234,6 +271,36 @@ fn classic_recording_remains_immediate_without_editor_save() { assert_eq!(s.actions.list[len].steps[0].1["color"], "#00ff00"); } +#[test] +fn rename_and_new_steps_cannot_introduce_play_or_conditional_self_calls() { + for (command, params) in [ + ("actions.play", json!({"action":"Future"})), + ("actions.conditional", json!({"if":"always","then":"Future"})), + ("actions.conditional", json!({"if":"always","then":"Other","else":"Future"})), + ] { + let (mut s, _) = session(); + let draft = open(&mut s, json!({"name":"Before"})); + editor(&mut s, "step", draft, json!({"op":"insert","step":0,"id":command,"params":params})).unwrap(); + let before = editor(&mut s, "get", draft, json!({})).unwrap(); + assert!(editor(&mut s, "update", draft, json!({"name":"Future"})).is_err()); + assert_eq!(editor(&mut s, "get", draft, json!({})).unwrap(), before); + editor(&mut s, "update", draft, json!({"name":"Allowed"})).unwrap(); + editor(&mut s, "discard", draft, json!({})).unwrap(); + let draft = open(&mut s, json!({"action":"Original"})); + editor(&mut s, "update", draft, json!({"name":"Renamed"})).unwrap(); + let mut params = params.clone(); + for key in ["action", "then", "else"] { + if params.get(key).is_some_and(|v| v == "Future") { + params[key] = json!("Original"); + } + } + assert!( + editor(&mut s, "step", draft, json!({"op":"insert","step":0,"id":command,"params":params})).is_err(), + "the opening source name will become Renamed at Save" + ); + } +} + /// Failed Save keeps an active take; successful Save consumes its pending steps only once. #[test] fn save_finishes_its_own_capture_only_after_complete_validation() { @@ -259,6 +326,32 @@ fn save_finishes_its_own_capture_only_after_complete_validation() { assert_eq!(s.journal, journal); } +/// A late job from a discarded take is journaled, but never enters the next draft capture. +#[test] +fn save_waits_for_a_real_recorded_job_before_publishing() { + let (mut s, store) = session(); + let draft = open(&mut s, json!({"name":"Worker capture"})); + editor(&mut s, "record", draft, json!({})).unwrap(); + let job = match s.start("filter.blur.gaussianBlur", json!({"radius":1})).unwrap() { + Started::Job(id) => id, + _ => panic!("blur must use its real job path"), + }; + assert!(s.has_recording_job()); + let state = s.actions.clone(); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + assert!(editor(&mut s, "save", draft, json!({})).is_err()); + assert_eq!(s.actions, state, "refusal retains the capture and unpublished draft"); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + s.wait_job(job).unwrap(); + assert!(!s.has_recording_job()); + let saved = editor(&mut s, "save", draft, json!({})).unwrap(); + assert_eq!(saved["recording"], false); + assert_eq!(s.actions.list.last().unwrap().steps[0].0, "filter.blur.gaussianBlur"); + let mut restored = Session::new(); + assert!(restored.attach_preset_store(preset_store::open(Box::new(store))).is_empty()); + assert_eq!(restored.actions.list.last().unwrap().steps, s.actions.list.last().unwrap().steps); +} + /// A late job from a discarded take is journaled, but never enters the next draft capture. #[test] fn late_job_cannot_enter_a_discarded_or_later_draft() { diff --git a/crates/engine/tests/it/actions_group_delete.rs b/crates/engine/tests/it/actions_group_delete.rs index 3aea3f3ba73..52dbaa5a266 100644 --- a/crates/engine/tests/it/actions_group_delete.rs +++ b/crates/engine/tests/it/actions_group_delete.rs @@ -5,6 +5,43 @@ use photocraft_engine::{ }; use serde_json::{Value, json}; +#[test] +fn deleting_a_namesake_keeps_named_calls_and_keys_until_the_last_target_is_gone() { + for group in [false, true] { + let mut s = Session::new(); + s.actions.list = vec![ + grouped("Duplicate", "First"), grouped("Duplicate", "Second"), + Action::new("Caller", vec![ + ("actions.play".into(), json!({"action":"Duplicate"})), + ("actions.play".into(), json!({"action":0})), + ("actions.play".into(), json!({"action":1})), + ("actions.conditional".into(), json!({"if":"always","then":"Duplicate","else":0})), + ]), + ]; + s.prefs.edit(|prefs| { + prefs.shortcuts.insert("actions.play:Duplicate".into(), "F5".into()); + prefs.alternate_shortcuts.insert("actions.play:Duplicate".into(), vec!["F6".into()]); + }); + s.execute("actions.delete", if group {json!({"set":"First"})} else {json!({"action":0})}).unwrap(); + assert_eq!(s.actions.list[0].name, "Duplicate"); + let calls = &s.actions.list[1].steps; + assert_eq!(calls[0].1["action"], "Duplicate"); + assert!(calls[1].1["action"].is_null()); + assert_eq!(calls[2].1["action"], 0); + assert_eq!(calls[3].1["then"], "Duplicate"); + assert!(calls[3].1["else"].is_null()); + assert_eq!(s.prefs().shortcuts["actions.play:Duplicate"], "F5"); + assert_eq!(s.prefs().alternate_shortcuts["actions.play:Duplicate"], ["F6"]); + s.execute("actions.delete", json!({"action":0})).unwrap(); + for (id, params) in &s.actions.list[0].steps { + if id == "actions.play" { assert!(params["action"].is_null()); } + else { assert!(params["then"].is_null()); } + } + assert!(!s.prefs().shortcuts.contains_key("actions.play:Duplicate")); + assert!(!s.prefs().alternate_shortcuts.contains_key("actions.play:Duplicate")); + } +} + fn grouped(name: &str, set: &str) -> Action { Action { set: Some(set.into()), ..Action::new(name, Vec::new()) } } From fb548fc7b68169e541a0e915a0c4f4dd6ce18c77 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:41:14 -0800 Subject: [PATCH 096/384] Keep Actions preview dialogs on their document and contrast thumbnail glyphs --- crates/ui-egui/src/dialogs/filter_dialog.rs | 2 +- crates/ui-egui/src/panels/actions.rs | 33 +++++--- .../src/panels/actions/editor_picker.rs | 2 +- .../src/panels/actions/editor_picker_tests.rs | 4 +- crates/ui-egui/src/panels/actions/modern.rs | 6 +- .../src/panels/actions/owned_preview_tests.rs | 80 +++++++++++++++++++ crates/ui-egui/src/theme.rs | 11 +++ crates/ui-egui/src/theme/tests.rs | 12 +++ 8 files changed, 134 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index afbda2de641..7a3f7e997e6 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -1127,7 +1127,7 @@ pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: }; let mut fields = fields; fields.insert("mapPath".into(), json!(path)); - crate::actions::with_step_gate(app, &fields, None, |app| { + crate::actions::with_step_gate_at(app, &fields, None, Some(doc), |app| { app.refocus(doc)?; let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?; let result = app.run(&cmd, params); diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 07c4f89c8ee..c4f4a9ab3ea 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -353,7 +353,8 @@ fn pause_frame(app: &mut PhotocraftApp, frame: &Frame, from: usize, dialog: u64) if app.ui.dialogs.iter().all(|d| d.id != dialog) { return Err("the action step dialog could not be opened".into()); } - app.paused_playbacks.pause(frame.after(from), dialog, app.session.authorize, app.session.actions.playback_steps) + let doc = app.session.active().map(|d| d.doc.id); + app.paused_playbacks.pause(frame.after(from), dialog, app.session.authorize, app.session.actions.playback_steps, doc) } /// Execute one immutable frame with normal shell view/save, authorization and job ordering. @@ -434,7 +435,8 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ break; } // Spelling owns its existing non-modal panel token, not a generic dialog id. - let token = match app.paused_playbacks.pause(frame.after(i + 1), 0, app.session.authorize, app.session.actions.playback_steps) { + let doc = app.session.active().map(|d| d.doc.id); + let token = match app.paused_playbacks.pause(frame.after(i + 1), 0, app.session.authorize, app.session.actions.playback_steps, doc) { Ok(token) => token, Err(error) => { app.ui.type_panels.spell = None; @@ -442,9 +444,6 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ break; } }; - if let Some(entry) = app.paused_playbacks.paused.get_mut(&token) { - entry.doc = app.session.active().map(|d| d.doc.id); - } if let Some(d) = app.ui.type_panels.spell.as_mut() { d.resume = Some(token); } @@ -975,8 +974,8 @@ pub(crate) struct PausedPlayback { ran: u64, used: u64, incremental: bool, - /// The document a pause at Check Spelling was playing on. That dialog doesn't block the - /// document tabs, so Done ([`resume_spelling`]) continues only on this document. + /// The document bound before opening the step dialog or non-modal Spelling panel. + /// Confirmation must validate it before executing a command or refocusing a deferred reply. doc: Option, } @@ -987,12 +986,12 @@ pub(crate) struct PausedPlaybacks { } impl PausedPlaybacks { - fn pause(&mut self, frame: Frame, dialog: u64, gate: Option, used: u64) -> Result { + fn pause(&mut self, frame: Frame, dialog: u64, gate: Option, used: u64, doc: Option) -> Result { let token = self.last.checked_add(1).ok_or("action step dialog identities exhausted")?; self.last = token; self.paused.insert( token, - PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used, incremental: false, doc: None }, + PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used, incremental: false, doc }, ); Ok(token) } @@ -1009,6 +1008,19 @@ pub(crate) fn with_step_gate( fields: &serde_json::Map, dialog: Option, run: impl FnOnce(&mut PhotocraftApp) -> Result, +) -> Result { + let doc = app.session.active().map(|doc| doc.doc.id); + with_step_gate_at(app, fields, dialog, doc, run) +} + +/// A deferred picker retains its original document even when another tab is active. +/// Validate that captured identity before the callback is allowed to refocus it. +pub(crate) fn with_step_gate_at( + app: &mut PhotocraftApp, + fields: &serde_json::Map, + dialog: Option, + doc: Option, + run: impl FnOnce(&mut PhotocraftApp) -> Result, ) -> Result { let Some(token) = fields.get(RESUME) else { if let Some(original) = dialog.and_then(|dialog| app.paused_playbacks.paused.iter().find_map(|(token, p)| (p.dialog == dialog).then_some(*token))) { @@ -1022,6 +1034,9 @@ pub(crate) fn with_step_gate( if dialog.is_some_and(|dialog| dialog != paused.dialog) { return Err("this dialog doesn't belong to that paused action".to_string()); } + if paused.doc != doc { + return Err("the active document changed while the action step dialog was open".to_string()); + } paused.frame.validate(&app.session)?; if let Some(owner) = &paused.record { owner.validate(&app.session).map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/panels/actions/editor_picker.rs b/crates/ui-egui/src/panels/actions/editor_picker.rs index 857d1a0087f..11cc8a4212f 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker.rs @@ -68,7 +68,7 @@ const COLOR_GRID: [(&str, &str); 12] = [ fn thumbnail_style(color: &str, swatches: &[Color32; 12], tokens: &Tokens) -> (Color32, Color32) { let index = BUTTON_COLORS.iter().position(|name| *name == color).filter(|index| *index > 0); match index.and_then(|index| swatches.get(index)).copied() { - Some(fill) => (fill, Color32::WHITE), + Some(fill) => (fill, Tokens::action_thumbnail_ink(fill)), None => (tokens.card, tokens.text), } } diff --git a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs index 9a5b4c68e42..c778145f4db 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs @@ -182,8 +182,8 @@ fn thumbnail_uses_the_callers_indexed_palette_and_theme_neutral_fallback() { for unknown_or_none in ["", UNKNOWN_COLOR, "future-color", "Blue", "blue2 "] { assert_eq!(thumbnail_style(unknown_or_none, &palette, &tokens), (tokens.card, tokens.text)); } - assert_eq!(thumbnail_style("blue", &palette, &tokens), (palette[5], Color32::WHITE)); - assert_eq!(thumbnail_style("blue2", &palette, &tokens), (palette[9], Color32::WHITE)); + assert_eq!(thumbnail_style("blue", &palette, &tokens).0, palette[5]); + assert_eq!(thumbnail_style("blue2", &palette, &tokens).0, palette[9]); assert_ne!(palette[5], palette[9]); } assert_eq!(super::display_glyph(UNKNOWN_ICON), "square"); diff --git a/crates/ui-egui/src/panels/actions/modern.rs b/crates/ui-egui/src/panels/actions/modern.rs index 90c5b409c67..75f5d84e9e9 100644 --- a/crates/ui-egui/src/panels/actions/modern.rs +++ b/crates/ui-egui/src/panels/actions/modern.rs @@ -334,7 +334,7 @@ fn thumbnail(ui: &mut egui::Ui, action: &Action, size: f32, tokens: &Tokens) -> let fill = color.and_then(|index| tokens.action_swatches().get(index).copied()).unwrap_or(tokens.card); ui.painter().rect_filled(rect, tokens.radius_sm, fill); let glyph = if action.icon.is_empty() { "action-editor-click" } else { editor_glyphs::display_glyph(&action.icon) }; - icons::paint(ui, rect, glyph, size * 0.6, if color.is_some() { Color32::WHITE } else { tokens.text }); + icons::paint(ui, rect, glyph, size * 0.6, if color.is_some() { Tokens::action_thumbnail_ink(fill) } else { tokens.text }); response } @@ -609,7 +609,7 @@ fn tick_with_quantum(app: &mut PhotocraftApp, ctx: &egui::Context, quantum: usiz .paused .iter() .filter_map(|(token, paused)| { - let open = if paused.doc.is_some() { + let open = if paused.dialog == 0 { app.ui.type_panels.spell.as_ref().is_some_and(|dialog| dialog.resume == Some(*token)) } else { app.ui.dialogs.iter().any(|dialog| dialog.id == paused.dialog) @@ -726,7 +726,7 @@ pub(super) fn cancel(app: &mut PhotocraftApp) { let tokens: Vec<_> = app.paused_playbacks.paused.iter().filter_map(|(token, paused)| paused.incremental.then_some(*token)).collect(); for token in tokens { if let Some(paused) = app.paused_playbacks.paused.remove(&token) { - if paused.doc.is_some() { + if paused.dialog == 0 { // Only this continuation owns the Spelling panel; a manual/unrelated one stays. if app.ui.type_panels.spell.as_ref().is_some_and(|dialog| dialog.resume == Some(token)) { app.ui.type_panels.spell = None; diff --git a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs index 673b75a0901..d81c8c1a8ad 100644 --- a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs +++ b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs @@ -35,6 +35,58 @@ fn blur_draft(app: &mut PhotocraftApp) -> u64 { id } +#[test] +fn generic_dialog_checks_the_document_after_the_real_window_control_route() { + for document in [0, 1] { + let mut app = app(); + app.session.execute("file.new", json!({"width":8,"height":6})).unwrap(); + assert!(app.session.set_active(0)); + let id = blur_draft(&mut app); + preview_draft(&mut app, id, 0).unwrap(); + let dialog = dialog(&app); + let (request, _reply) = crate::control::ControlRequest::new("ui.window.open", json!({"document":document})); + let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &egui::Context::default(), &request) else { + panic!("the window control must return immediately without a rendering frame"); + }; + assert_eq!(reply["ok"], true, "{reply}"); + let other = app.session.documents()[1].doc.clone(); + let result = crate::dialogs::confirm(&mut app, dialog); + if document == 1 { + assert!(result.is_err()); + assert!(!has_layer(&app, "Suffix")); + } else { + result.unwrap(); + assert!(has_layer(&app, "Suffix")); + } + assert!(std::sync::Arc::ptr_eq(&other, &app.session.documents()[1].doc)); + assert!(app.session.actions.held_playback.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + } +} + +#[test] +fn preview_from_a_selected_step_skips_the_prefix_and_retains_dialog_indices() { + let mut app = app(); + let id = draft( + &mut app, + &[ + ("layer.new.layer", json!({"name":"Skipped prefix"})), + ("filter.blur.gaussianBlur", json!({"radius":1})), + ("layer.new.layer", json!({"name":"Suffix"})), + ], + ); + app.run("actions.editor.step", json!({"draft":id,"op":"dialog","step":1,"enabled":true})).unwrap(); + let before = get(&mut app, id); + let played = preview_draft(&mut app, id, 1).unwrap(); + assert_eq!(played["paused"]["step"], 1); + assert!(!has_layer(&app, "Skipped prefix")); + let dialog = dialog(&app); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(has_layer(&app, "Suffix")); + assert!(!has_layer(&app, "Skipped prefix")); + assert_eq!(get(&mut app, id), before); +} + #[test] fn unpublished_preview_preserves_snapshot_indices_flags_and_metadata() { let mut app = app(); @@ -262,6 +314,34 @@ fn deferred_displace_checks_the_owner_and_gate_before_refocus_or_command() { } } +#[test] +fn a_valid_deferred_map_refocuses_its_original_document_and_resumes_the_suffix() { + let directory = tempfile::tempdir().unwrap(); + let map = directory.path().join("map.png"); + let mut source = photocraft_engine::Session::new(); + source.execute("file.new", json!({"width":8,"height":6,"background":"white"})).unwrap(); + std::fs::write(&map, photocraft_io::export(&source.active().unwrap().doc, "map.png", &Default::default()).unwrap().bytes).unwrap(); + let mut app = app(); + let id = draft(&mut app, &[("filter.distort.displace", json!({"horizontal":1,"vertical":1})), ("layer.new.layer", json!({"name":"Suffix"}))]); + app.run("actions.editor.step", json!({"draft":id,"op":"dialog","step":0,"enabled":true})).unwrap(); + let (show, asked) = crate::file_dialog::fake(vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec![map.to_string_lossy().into_owned()]))]); + app.services.file_dialog = Some(show); + preview_draft(&mut app, id, 0).unwrap(); + let dialog = dialog(&app); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.file_dialog_open()); + app.session.execute("file.new", json!({"width":4,"height":4})).unwrap(); + let other = app.session.documents()[1].doc.clone(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(asked.borrow().len(), 1); + assert_eq!(app.session.active_index(), Some(0)); + assert!(has_layer(&app, "Suffix")); + assert!(std::sync::Arc::ptr_eq(&other, &app.session.documents()[1].doc)); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(app.session.actions.held_playback.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); +} + #[test] fn a_resumed_preview_keeps_its_tree_step_budget() { let mut app = app(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4ce62d13c17..73df15bf75a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -214,6 +214,17 @@ pub struct Tokens { } impl Tokens { + /// Actions use both light and dark swatches, including the theme's dim text colour. + /// Choose the ink with the greater WCAG contrast against the actual supplied fill. + pub fn action_thumbnail_ink(fill: Color32) -> Color32 { + let linear = |channel: u8| { + let value = f32::from(channel) / 255.0; + if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) } + }; + let luminance = 0.2126 * linear(fill.r()) + 0.7152 * linear(fill.g()) + 0.0722 * linear(fill.b()); + if (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) { Color32::BLACK } else { Color32::WHITE } + } + /// Semantic Actions thumbnail palette in ATN colour-index order. These are application /// colours, independent of stored metadata; uncoloured/unknown values use the card tokens. pub fn action_swatches(&self) -> [Color32; 12] { diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index d869649853d..149666444b1 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -1,5 +1,17 @@ use super::*; +#[test] +fn action_thumbnail_glyphs_remain_readable_on_every_theme_swatch() { + for kind in ThemeKind::ALL { + for fill in Tokens::for_kind(kind).action_swatches().into_iter().skip(1) { + let ink = Tokens::action_thumbnail_ink(fill); + assert!(contrast(ink, fill) >= 4.5, "{kind:?}: {ink:?} on {fill:?}"); + } + } + assert_eq!(Tokens::action_thumbnail_ink(Color32::from_rgb(255, 240, 90)), Color32::BLACK); + assert_eq!(Tokens::action_thumbnail_ink(Color32::from_rgb(20, 25, 35)), Color32::WHITE); +} + fn text_sizes(ctx: &egui::Context) -> Vec { let mut sizes = Vec::new(); // The plugin queues changed fonts at the end of a pass; egui applies them next pass. From 23e8afa89f425c19998b62406d41c556af118eb7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:43:57 -0800 Subject: [PATCH 097/384] Use external ATN input limits in CLI batch loading --- apps/photocraft-cli/src/actions.rs | 8 ++--- apps/photocraft-cli/tests/it/cli.rs | 50 +++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 444769336c2..8341972c2d9 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -82,10 +82,10 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), ActionsError> { let file = std::fs::File::open(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; let mut bytes = Vec::new(); - file.take(photocraft_engine::preset_store::MAX_ACTIONS_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; - if bytes.len() as u64 > photocraft_engine::preset_store::MAX_ACTIONS_BYTES { + // CLI batch input is an external action file, not the smaller persisted preset store. + let limit = photocraft_engine::preset_import_cmds::MAX_FILE_BYTES; + file.take(limit + 1).read_to_end(&mut bytes).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; + if bytes.len() as u64 > limit { return Err(ActionsError::Read { path: path.to_path_buf(), source: std::io::Error::new(std::io::ErrorKind::InvalidData, "Actions input exceeds byte limit"), diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index be4d274361b..6333eb575a3 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1742,7 +1742,51 @@ fn batch_rejects_a_legacy_json_library_before_creating_output() { let d = tmp("legacy-action-library-rejected"); let file = d.join("legacy-library.json"); std::fs::write(&file, r#"{"version":1,"actions":[{"name":"Old","steps":[]}]}"#).unwrap(); - let result = photocraft_cli::load_actions_file(&file, Some("Old")); - assert!(result.unwrap_err().to_string().contains("legacy JSON action libraries")); - assert!(!d.join("out").exists()); + let output = d.join("out"); + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let args = [ + "batch".as_ref(), + "--in".as_ref(), + d.as_os_str(), + "--out".as_ref(), + output.as_os_str(), + "--actions".as_ref(), + file.as_os_str(), + "--action".as_ref(), + "Old".as_ref(), + ]; + assert_eq!(photocraft_cli::run(args, &mut stdout, &mut stderr), 1); + assert!(String::from_utf8(stderr).unwrap().contains("legacy JSON action libraries")); + assert!(!output.exists()); +} + +#[test] +fn batch_external_atn_input_is_not_limited_by_the_preset_store_size() { + use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, UnicodeString, Value}, + }; + let directory = tmp("large-external-action-input"); + let file = directory.join("external.atn"); + let bytes = atn::write(&AtnFile { + name: UnicodeString::new_nul("External"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Opaque"), + items: vec![AtnItem { + event: AtnEvent::name("futureOpaqueEvent"), + descriptor: Some(Descriptor::new("null").with("payload", Value::RawData(vec![37; 17 << 20]))), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap(); + assert!(bytes.len() as u64 > photocraft_engine::preset_store::MAX_ACTIONS_BYTES); + std::fs::write(&file, bytes).unwrap(); + let (steps, actions, warnings) = photocraft_cli::load_actions_file(&file, Some("Opaque")).unwrap(); + assert_eq!(steps[0].0, "actions.play"); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].steps[0].0, photocraft_engine::atn_cmds::PHOTOSHOP_STEP); + assert_eq!(warnings.len(), 1); } From 13a830c6c1c9aefa68f421a926437362d7d75d6f Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:45:56 -0800 Subject: [PATCH 098/384] Label the Actions picker for both icon and color choices --- crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/actions/editor_picker.rs | 2 +- crates/ui-egui/src/panels/actions/editor_picker_tests.rs | 2 +- 27 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 157d301f580..bf0d37d884a 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3562,6 +3562,7 @@ curves Smooth تنعيم Cancel and continue editing إلغاء ومتابعة التحرير Choose color اختيار اللون Choose icon اختيار الأيقونة + Choose icon and color اختيار الأيقونة واللون Classic Actions الإجراءات الكلاسيكية Clear All Actions مسح جميع الإجراءات Clear search مسح البحث diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ead8982b09c..19274086976 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4278,6 +4278,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Zrušit a pokračovat v úpravách Choose color Vybrat barvu Choose icon Vybrat ikonu + Choose icon and color Vybrat ikonu a barvu Classic Actions Klasické akce Clear All Actions Vymazat všechny akce Clear search Vymazat hledání diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 119ffa7bd99..a30815902dd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4785,6 +4785,7 @@ cameraRaw White Balance: As Shot Hvidbalance: Som fotograferet Cancel and continue editing Annuller og fortsæt redigeringen Choose color Vælg farve Choose icon Vælg ikon + Choose icon and color Vælg ikon og farve Classic Actions Klassiske handlinger Clear All Actions Ryd alle handlinger Clear search Ryd søgningen diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d55876f654..2cb711a0b9d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3564,6 +3564,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Abbrechen und weiter bearbeiten Choose color Farbe wählen Choose icon Symbol wählen + Choose icon and color Symbol und Farbe wählen Classic Actions Klassische Aktionen Clear All Actions Alle Aktionen löschen Clear search Suche löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f686f2b754d..b6784e4cccc 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4232,6 +4232,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Ακύρωση και συνέχεια επεξεργασίας Choose color Επιλογή χρώματος Choose icon Επιλογή εικονιδίου + Choose icon and color Επιλογή εικονιδίου και χρώματος Classic Actions Κλασικές ενέργειες Clear All Actions Εκκαθάριση όλων των ενεργειών Clear search Εκκαθάριση αναζήτησης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7dc499accd0..7ad08f55596 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4444,6 +4444,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Cancelar y seguir editando Choose color Elegir color Choose icon Elegir icono + Choose icon and color Elegir icono y color Classic Actions Acciones clásicas Clear All Actions Borrar todas las acciones Clear search Borrar búsqueda diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3cd4e9a3c2c..c0d691ab956 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4786,6 +4786,7 @@ cameraRaw White Balance: As Shot Valkotasapaino: kuvauksen mukainen Cancel and continue editing Peruuta ja jatka muokkaamista Choose color Valitse väri Choose icon Valitse kuvake + Choose icon and color Valitse kuvake ja väri Classic Actions Perinteiset toiminnot Clear All Actions Tyhjennä kaikki toiminnot Clear search Tyhjennä haku diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 46dd6c3fcb7..cd40557de85 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4568,6 +4568,7 @@ shape Appearance Apparence Cancel and continue editing Annuler et continuer la modification Choose color Choisir une couleur Choose icon Choisir une icône + Choose icon and color Choisir une icône et une couleur Classic Actions Actions classiques Clear All Actions Effacer toutes les actions Clear search Effacer la recherche diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a521c5224af..8e762c5e79d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4452,6 +4452,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Mégse és a szerkesztés folytatása Choose color Szín kiválasztása Choose icon Ikon kiválasztása + Choose icon and color Ikon és szín kiválasztása Classic Actions Klasszikus műveletsorok Clear All Actions Összes művelet törlése Clear search Keresés törlése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8bd7b02f2e3..34d0e6c7a4b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3556,6 +3556,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Batal dan lanjutkan mengedit Choose color Pilih warna Choose icon Pilih ikon + Choose icon and color Pilih ikon dan warna Classic Actions Action Klasik Clear All Actions Hapus Semua Action Clear search Hapus pencarian diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 79cee94e0ea..8bec13eed4e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4644,6 +4644,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Annulla e continua a modificare Choose color Scegli colore Choose icon Scegli icona + Choose icon and color Scegli icona e colore Classic Actions Azioni classiche Clear All Actions Cancella tutte le azioni Clear search Cancella ricerca diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1daa190c1c5..77f64f42f3b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4210,6 +4210,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing キャンセルして編集を続ける Choose color 色を選択 Choose icon アイコンを選択 + Choose icon and color アイコンと色を選択 Classic Actions クラシックアクション Clear All Actions すべてのアクションを消去 Clear search 検索をクリア diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9cea628b3ba..42f5c851d9f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3753,6 +3753,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing 취소하고 계속 편집 Choose color 색상 선택 Choose icon 아이콘 선택 + Choose icon and color 아이콘 및 색상 선택 Classic Actions 클래식 액션 Clear All Actions 모든 액션 지우기 Clear search 검색 지우기 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 4f56d00d800..0c7593503c5 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -4785,6 +4785,7 @@ cameraRaw White Balance: As Shot Hvitbalanse: som fotografert Cancel and continue editing Avbryt og fortsett å redigere Choose color Velg farge Choose icon Velg ikon + Choose icon and color Velg ikon og farge Classic Actions Klassiske handlinger Clear All Actions Tøm alle handlinger Clear search Tøm søket diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6475b57d65..8b26dcc1e71 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4289,6 +4289,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Annuleren en verder bewerken Choose color Kleur kiezen Choose icon Pictogram kiezen + Choose icon and color Pictogram en kleur kiezen Classic Actions Klassieke acties Clear All Actions Alle acties wissen Clear search Zoekopdracht wissen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3c492e1b7a9..98926ba3937 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4152,6 +4152,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Anuluj i kontynuuj edycję Choose color Wybierz kolor Choose icon Wybierz ikonę + Choose icon and color Wybierz ikonę i kolor Classic Actions Klasyczne akcje Clear All Actions Wyczyść wszystkie akcje Clear search Wyczyść wyszukiwanie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5bcb4135245..44a9d37bb81 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4459,6 +4459,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Cancelar e continuar editando Choose color Escolher cor Choose icon Escolher ícone + Choose icon and color Escolher ícone e cor Classic Actions Ações clássicas Clear All Actions Limpar todas as ações Clear search Limpar pesquisa diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bac5dd8f562..2bbe657e8f5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4729,6 +4729,7 @@ shape Appearance Оформление Cancel and continue editing Отменить и продолжить редактирование Choose color Выбрать цвет Choose icon Выбрать значок + Choose icon and color Выбрать значок и цвет Classic Actions Классические операции Clear All Actions Очистить все операции Clear search Очистить поиск diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index acea9889294..0427e0a5399 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4611,6 +4611,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Otkaži i nastavi uređivanje Choose color Izaberi boju Choose icon Izaberi ikonu + Choose icon and color Izaberi ikonu i boju Classic Actions Klasične radnje Clear All Actions Obriši sve radnje Clear search Obriši pretragu diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ec0b8d604ac..4c554a21241 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4783,6 +4783,7 @@ cameraRaw White Balance: As Shot Vitbalans: som tagen Cancel and continue editing Avbryt och fortsätt redigera Choose color Välj färg Choose icon Välj ikon + Choose icon and color Välj ikon och färg Classic Actions Klassiska åtgärder Clear All Actions Rensa alla åtgärder Clear search Rensa sökningen diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 77bdde71c27..c3f7f8be3b6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4266,6 +4266,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing İptal et ve düzenlemeye devam et Choose color Renk seç Choose icon Simge seç + Choose icon and color Simge ve renk seç Classic Actions Klasik Eylemler Clear All Actions Tüm Eylemleri temizle Clear search Aramayı temizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37854270398..f677f44697b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4244,6 +4244,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Скасувати й продовжити редагування Choose color Вибрати колір Choose icon Вибрати піктограму + Choose icon and color Вибрати піктограму й колір Classic Actions Класичні дії Clear All Actions Очистити всі дії Clear search Очистити пошук diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 09b87efeadb..269e27badb1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4040,6 +4040,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing Hủy và tiếp tục chỉnh sửa Choose color Chọn màu Choose icon Chọn biểu tượng + Choose icon and color Chọn biểu tượng và màu Classic Actions Thao tác cổ điển Clear All Actions Xóa tất cả thao tác Clear search Xóa tìm kiếm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 53b157278ad..69463b2f7a2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3218,6 +3218,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing 取消并继续编辑 Choose color 选择颜色 Choose icon 选择图标 + Choose icon and color 选择图标和颜色 Classic Actions 经典动作 Clear All Actions 清除所有动作 Clear search 清除搜索 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6026f454d40..cca5afb4eed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3174,6 +3174,7 @@ smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded Cancel and continue editing 取消並繼續編輯 Choose color 選擇顏色 Choose icon 選擇圖示 + Choose icon and color 選擇圖示與色彩 Classic Actions 傳統動作 Clear All Actions 清除所有動作 Clear search 清除搜尋 diff --git a/crates/ui-egui/src/panels/actions/editor_picker.rs b/crates/ui-egui/src/panels/actions/editor_picker.rs index 11cc8a4212f..766a92b9a51 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker.rs @@ -146,7 +146,7 @@ pub fn show(ui: &mut egui::Ui, id: Id, icon: &str, color: &str, swatches: &[Colo let (fill, ink) = thumbnail_style(color, swatches, &tokens); ui.painter().rect_filled(rect, tokens.radius_sm, fill); icons::paint(ui, rect, display_glyph(icon), 28.0, ink); - ui.menu_button(tl!("Choose icon"), |ui| choice = contents(ui, icon, color, swatches, &tokens)); + ui.menu_button(tl!("Choose icon and color"), |ui| choice = contents(ui, icon, color, swatches, &tokens)); }); choice }) diff --git a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs index c778145f4db..efbb7765a48 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs @@ -42,7 +42,7 @@ fn harness(icon: &str, color: &str) -> Harness<'static, State> { fn open(h: &mut Harness<'_, State>) { assert!(!egui::Popup::is_any_open(&h.ctx)); - h.get_by_role_and_label(Role::Button, "Choose icon").click(); + h.get_by_role_and_label(Role::Button, "Choose icon and color").click(); h.run_steps(3); } From 3c9bc2d5881ae944f5eed4d059c019fe489fae33 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:55:34 -0800 Subject: [PATCH 099/384] Reconcile exact browser Actions migration retries in one transaction --- apps/photocraft-web/src/indexed_presets.rs | 91 ++++++++----- .../src/indexed_presets/migration.rs | 122 ++++++++++++++++++ apps/photocraft-web/src/preset_bridge.rs | 56 +++++++- apps/photocraft-web/src/web.rs | 6 +- 4 files changed, 234 insertions(+), 41 deletions(-) create mode 100644 apps/photocraft-web/src/indexed_presets/migration.rs diff --git a/apps/photocraft-web/src/indexed_presets.rs b/apps/photocraft-web/src/indexed_presets.rs index 0c6161cc3a2..b83c0ebca1e 100644 --- a/apps/photocraft-web/src/indexed_presets.rs +++ b/apps/photocraft-web/src/indexed_presets.rs @@ -12,6 +12,8 @@ const DATABASE: &str = "photocraft.presets"; const FILES: &str = "files"; const TIMEOUT_MS: f64 = 10_000.0; +mod migration; + fn js(e: JsValue) -> String { if let Some(exception) = e.dyn_ref::() { format!("{}: {}", exception.name(), exception.message()) @@ -231,47 +233,57 @@ async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec), Ok((bridge, warnings)) } -pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> { +/// Queue only while the transaction is active (creation or an IDB request callback). +fn queue_batch(store: &web_sys::IdbObjectStore, batch: &Batch, committed: bool) -> Result<(), String> { + for (name, bytes) in batch { + if committed && matches!(name.as_str(), ACTIONS_FILE | LEGACY_ACTIONS_FILE | MIGRATED_ACTIONS_FILE) { + continue; + } + let key = JsValue::from_str(name); + if let Some(bytes) = bytes { + if let Some(migration) = &batch.migration + && name == ACTIONS_FILE + && migration.published + { + continue; + } + let blob = Blob::new_with_u8_array_sequence(&Array::of1(&Uint8Array::from(bytes.as_ref()))).map_err(js)?; + let exclusive = batch.migration.as_ref().is_some_and(|migration| name == MIGRATED_ACTIONS_FILE || (name == ACTIONS_FILE && !migration.published)); + if exclusive { + store.add_with_key(&blob, &key).map_err(js)?; + } else { + store.put_with_key(&blob, &key).map_err(js)?; + } + } else { + store.delete(&key).map_err(js)?; + } + } + Ok(()) +} + +pub async fn write(db: &std::rc::Rc>, batch: &Batch) -> Result<(), String> { if let Some(migration) = &batch.migration { if !matches!(batch.get(LEGACY_ACTIONS_FILE), Some(None)) || batch.get(MIGRATED_ACTIONS_FILE).and_then(Option::as_ref).is_none_or(|bytes| bytes.as_ref() != migration.legacy.as_ref()) - || batch.get(ACTIONS_FILE).and_then(Option::as_ref).is_none() + || batch.get(ACTIONS_FILE).and_then(Option::as_ref).is_none_or(|bytes| bytes.as_ref() != migration.atn.as_ref()) { return Err("incomplete Actions migration transaction".into()); } } - let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?; - let store = tx.object_store(FILES).map_err(js)?; - let queued = (|| -> Result<(), String> { - for (name, bytes) in batch { - let key = JsValue::from_str(name); - if let Some(bytes) = bytes { - if let Some(migration) = &batch.migration { - if name == ACTIONS_FILE && migration.published && bytes.as_ref() == migration.atn.as_ref() { - continue; - } - if name == MIGRATED_ACTIONS_FILE && bytes.as_ref() != migration.legacy.as_ref() { - return Err("migration backup changed while staged".into()); - } - } - let blob = Blob::new_with_u8_array_sequence(&Array::of1(&Uint8Array::from(bytes.as_ref()))).map_err(js)?; - let exclusive = - batch.migration.as_ref().is_some_and(|migration| name == MIGRATED_ACTIONS_FILE || (name == ACTIONS_FILE && !migration.published)); - if exclusive { - store.add_with_key(&blob, &key).map_err(js)?; - } else { - store.put_with_key(&blob, &key).map_err(js)?; - } - } else { - store.delete(&key).map_err(js)?; - } + let deadline = js_sys::Date::now() + TIMEOUT_MS; + // A versionchange handler closes the old connection. Retry replaces only that + // connection; it must not rehydrate over the live Bridge's newer unsaved bytes. + let connection = db.borrow().clone(); + let tx = match connection.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite) { + Ok(tx) => tx, + Err(error) if error.dyn_ref::().is_some_and(|error| error.name() == "InvalidStateError") => { + let connection = open(deadline).await?; + *db.borrow_mut() = connection.clone(); + connection.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)? } - Ok(()) - })(); - if let Err(e) = queued { - let _ = tx.abort(); - return Err(e); - } + Err(error) => return Err(js(error)), + }; + let store = tx.object_store(FILES).map_err(js)?; let mut listeners = Listeners::new(); let promise = Promise::new(&mut |resolve, reject| { if let Err(e) = listeners.add(&tx, "complete", move |_| { @@ -291,9 +303,18 @@ pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> { } } }); - let result = before(&promise, js_sys::Date::now() + TIMEOUT_MS).await.map(|_| ()); + let queued = + if batch.migration.is_some() { migration::start(&tx, &store, batch, deadline).map(Some) } else { queue_batch(&store, batch, false).map(|_| None) }; + let scope = match queued { + Ok(scope) => scope, + Err(error) => { + let _ = tx.abort(); + return Err(error); + } + }; + let result = before(&promise, deadline).await.map(|_| ()); if result.is_err() { let _ = tx.abort(); } - result + scope.as_ref().and_then(migration::Scope::error).map_or(result, Err) } diff --git a/apps/photocraft-web/src/indexed_presets/migration.rs b/apps/photocraft-web/src/indexed_presets/migration.rs new file mode 100644 index 00000000000..2ea0f5e9a36 --- /dev/null +++ b/apps/photocraft-web/src/indexed_presets/migration.rs @@ -0,0 +1,122 @@ +//! Migration-only transaction liveness while immutable Blob bytes are compared. +//! Blob futures publish a decision; only an active IDB request callback queues mutations. +use std::{cell::RefCell, rc::Rc}; + +use super::*; + +type Callback = Rc>>>; + +#[derive(Default)] +struct Decision { + ready: Option>, + stopped: bool, + error: Option, +} + +pub(super) struct Scope { + decision: Rc>, + current: Rc>>, + callback: Callback, +} + +impl Scope { + pub(super) fn error(&self) -> Option { + self.decision.borrow().error.clone() + } +} + +impl Drop for Scope { + fn drop(&mut self) { + self.decision.borrow_mut().stopped = true; + if let Some(request) = self.current.borrow_mut().take() { + request.set_onsuccess(None); + } + self.callback.borrow_mut().take(); + } +} + +fn next(store: &web_sys::IdbObjectStore, current: &Rc>>, callback: &Callback) -> Result<(), String> { + let request = store.count().map_err(js)?; + let borrowed = callback.borrow(); + let handler = borrowed.as_ref().ok_or("migration callback was released")?; + request.set_onsuccess(Some(handler.as_ref().unchecked_ref())); + if let Some(previous) = current.borrow_mut().replace(request) { + previous.set_onsuccess(None); + } + Ok(()) +} + +async fn bytes(value: JsValue, deadline: f64) -> Result>, String> { + if value.is_undefined() { + return Ok(None); + } + let blob = value.dyn_into::().map_err(|_| "migration record is not a Blob")?; + if blob.size() > photocraft_engine::preset_store::MAX_ACTIONS_BYTES as f64 { + return Err("migration record exceeds the Actions file limit".into()); + } + let buffer = before(&blob.array_buffer(), deadline).await?; + Ok(Some(Uint8Array::new(&buffer).to_vec())) +} + +pub(super) fn start(tx: &web_sys::IdbTransaction, store: &web_sys::IdbObjectStore, batch: &Batch, deadline: f64) -> Result { + let migration = batch.migration.clone().ok_or("missing migration identity")?; + let mut reads = Vec::new(); + let promises = Array::new(); + for name in [ACTIONS_FILE, LEGACY_ACTIONS_FILE, MIGRATED_ACTIONS_FILE] { + let pending = request(&store.get(&JsValue::from_str(name)).map_err(js)?); + promises.push(&pending.promise); + reads.push(pending); + } + let decision = Rc::new(RefCell::new(Decision::default())); + let current = Rc::new(RefCell::new(None)); + let callback: Callback = Rc::new(RefCell::new(None)); + let weak = Rc::downgrade(&callback); + let state = decision.clone(); + let active = current.clone(); + let transaction = tx.clone(); + let files = store.clone(); + let owned = batch.clone(); + *callback.borrow_mut() = Some(Closure::new(move |_: Event| { + let result = { + let mut state = state.borrow_mut(); + if state.stopped { + return; + } + if js_sys::Date::now() >= deadline { Some(Err("browser preset storage timed out".into())) } else { state.ready.take() } + }; + let result = match result { + Some(result) => { + // This callback is an active IDB request event. No Blob future issues writes. + state.borrow_mut().stopped = true; + result.and_then(|committed| queue_batch(&files, &owned, committed)) + } + None => weak.upgrade().ok_or_else(|| "migration callback was released".to_string()).and_then(|callback| next(&files, &active, &callback)), + }; + if let Err(error) = result { + let mut state = state.borrow_mut(); + state.stopped = true; + state.error = Some(error); + let _ = transaction.abort(); + } + })); + let scope = Scope { decision: decision.clone(), current, callback }; + next(store, &scope.current, &scope.callback)?; + wasm_bindgen_futures::spawn_local(async move { + let result = async { + let values = before(&Promise::all(&promises), deadline).await?; + let values = Array::from(&values); + let atn = bytes(values.get(0), deadline).await?; + let legacy = bytes(values.get(1), deadline).await?; + let backup = bytes(values.get(2), deadline).await?; + migration.already_committed(atn.as_deref(), legacy.as_deref(), backup.as_deref()) + } + .await; + // A timeout or abort may have finished the attempt. Late reads have no IDB effects. + let mut state = decision.borrow_mut(); + if !state.stopped { + state.ready = Some(result); + } + drop(reads); + }); + Ok(scope) +} diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index 58229404500..40f030f24b4 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -15,6 +15,19 @@ pub struct Migration { pub atn: Arc<[u8]>, pub published: bool, } +impl Migration { + /// Called only with bytes read under the writer's single readwrite transaction. + pub fn already_committed(&self, atn: Option<&[u8]>, legacy: Option<&[u8]>, backup: Option<&[u8]>) -> Result { + if atn == Some(self.atn.as_ref()) && legacy.is_none() && backup == Some(self.legacy.as_ref()) { + return Ok(true); + } + if legacy == Some(self.legacy.as_ref()) && backup.is_none() && atn == self.published.then_some(self.atn.as_ref()) { + return Ok(false); + } + Err("Actions migration records changed; original, ATN and backup retained unchanged".into()) + } +} +#[derive(Clone)] pub struct Batch { pub files: Files, pub migration: Option, @@ -105,13 +118,23 @@ impl Bridge { return None; } s.writing = true; - let files = std::mem::take(&mut s.dirty) + let mut files: Files = std::mem::take(&mut s.dirty) .into_iter() .map(|name| { let bytes = s.files.get(&name).cloned(); (name, bytes) }) .collect(); + if let Some(migration) = s.migration.clone() { + // Every ambiguous retry has the same exact identity. Newer live edits wait + // for this migration's acknowledgement, then get their own ordinary batch. + if s.files.get(ACTIONS_FILE).map(AsRef::as_ref) != Some(migration.atn.as_ref()) { + s.dirty.insert(ACTIONS_FILE.into()); + } + files.insert(ACTIONS_FILE.into(), Some(migration.atn)); + files.insert(MIGRATED_ACTIONS_FILE.into(), Some(migration.legacy)); + files.insert(LEGACY_ACTIONS_FILE.into(), None); + } Some(Batch { files, migration: s.migration.clone() }) } @@ -279,11 +302,13 @@ mod tests { } #[test] - fn actions_migration_abort_requeues_whole_group_with_latest_atn() { + fn actions_migration_abort_retries_fixed_bytes_before_the_latest_atn() { let bridge = Bridge::default(); bridge.load(LEGACY_ACTIONS_FILE, b"legacy".to_vec()).unwrap(); bridge.migrate_actions(b"legacy", b"first ATN", false).unwrap(); + bridge.write(ACTIONS_FILE, b"edited before begin").unwrap(); let batch = bridge.begin().unwrap(); + assert_eq!(batch.get(ACTIONS_FILE).unwrap().as_deref(), Some(&b"first ATN"[..])); bridge.write(ACTIONS_FILE, b"edited ATN").unwrap(); bridge.finish(batch, Err("transaction aborted".into())); assert!(bridge.unsaved()); @@ -292,12 +317,37 @@ mod tests { let batch = bridge.begin().unwrap(); assert!(batch.migration.is_some()); assert_eq!(batch.len(), 3); - assert_eq!(batch.get(ACTIONS_FILE).unwrap().as_deref(), Some(&b"edited ATN"[..])); + assert_eq!(batch.get(ACTIONS_FILE).unwrap().as_deref(), Some(&b"first ATN"[..])); assert_eq!(batch.get(MIGRATED_ACTIONS_FILE).unwrap().as_deref(), Some(&b"legacy"[..])); bridge.finish(batch, Ok(())); + assert!(bridge.unsaved()); + let next = bridge.begin().unwrap(); + assert!(next.migration.is_none()); + assert_eq!(next.len(), 1); + assert_eq!(next.get(ACTIONS_FILE).unwrap().as_deref(), Some(&b"edited ATN"[..])); + bridge.finish(next, Ok(())); assert!(!bridge.unsaved()); } + #[test] + fn migration_recognizes_only_the_exact_fresh_or_committed_tuple() { + for published in [false, true] { + let migration = Migration { legacy: Arc::from(&b"legacy"[..]), atn: Arc::from(&b"ATN0"[..]), published }; + assert!(!migration.already_committed(published.then_some(&b"ATN0"[..]), Some(b"legacy"), None).unwrap()); + assert!(migration.already_committed(Some(b"ATN0"), None, Some(b"legacy")).unwrap()); + for (atn, legacy, backup) in [ + (Some(&b"ATN1"[..]), None, Some(&b"legacy"[..])), + (Some(&b"ATN0"[..]), Some(&b"change"[..]), None), + (Some(&b"ATN0"[..]), None, Some(&b"change"[..])), + (Some(&b"ATN0"[..]), Some(&b"legacy"[..]), Some(&b"legacy"[..])), + (None, None, Some(&b"legacy"[..])), + (Some(&b""[..]), None, Some(&b"legacy"[..])), + ] { + assert!(migration.already_committed(atn, legacy, backup).is_err()); + } + } + } + #[test] fn actions_migration_validates_all_bindings_and_limits_before_mutation() { let bridge = Bridge::default(); diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 024888c65d1..394f68ecefe 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -1,6 +1,6 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -67,7 +67,7 @@ pub fn start() { // Hydrate before constructing an interactive session: a late load must never undo a // user deletion/reorder. Storage denial or timeout still opens a usable session. let (database, presets, mut preset_warnings) = match crate::indexed_presets::load().await { - Ok((db, bridge, warnings)) => (Some(db), bridge, warnings), + Ok((db, bridge, warnings)) => (Some(Rc::new(RefCell::new(db))), bridge, warnings), Err(e) => { let bridge = crate::preset_bridge::Bridge::default(); bridge.unavailable(e); @@ -271,7 +271,7 @@ struct WebShell { /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, - database: Option, + database: Option>>, presets: crate::preset_bridge::Bridge, /// The interface language whose served script font was last asked for. ui_language: &'static str, From ea9ec0f462e3b1e763176adb7d1846347886d9a8 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:56:55 -0800 Subject: [PATCH 100/384] Cover picker choices through owned Actions draft Save and reload --- .../src/panels/actions/editor_tests.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/ui-egui/src/panels/actions/editor_tests.rs b/crates/ui-egui/src/panels/actions/editor_tests.rs index 406bacf3678..07d78cd5bac 100644 --- a/crates/ui-egui/src/panels/actions/editor_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_tests.rs @@ -28,6 +28,51 @@ fn store(mem: &MemBackend) -> std::collections::BTreeMap> { mem.files.lock().unwrap().clone() } +#[test] +fn picker_choices_reach_the_owned_draft_and_only_save_publishes_them() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + struct State { + app: PhotocraftApp, + page: Page, + } + let (mut app, mem) = app(Default::default()); + let page = existing(&mut app); + let before = app.session.actions.list.clone(); + let persisted = store(&mem); + let mut harness = Harness::builder().with_size(egui::vec2(650.0, 750.0)).build_ui_state( + |ui, state: &mut State| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + let view = state.page.get(&mut state.app).unwrap(); + let swatches = crate::theme::Tokens::get(ui.ctx()).action_swatches(); + let _ = state.page.metadata(&mut state.app, ui, &view, &swatches); + }, + State { app, page }, + ); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::Studio); + harness.run_steps(3); + for label in ["Text colour", "Yellow"] { + harness.get_by_role_and_label(Role::Button, "Choose icon and color").click(); + harness.run_steps(3); + harness.get_by_role_and_label(Role::Button, label).click(); + harness.run_steps(3); + } + let state = harness.state_mut(); + let view = state.page.get(&mut state.app).unwrap(); + assert_eq!(action_text(&view, "icon"), "TextColor"); + assert_eq!(action_text(&view, "color"), "yellow"); + assert_eq!(state.app.session.actions.list, before); + assert_eq!(store(&mem), persisted); + state.page.ask_save(&mut state.app, Activity::default()).unwrap(); + assert_eq!(state.page.answer(&mut state.app, Answer::Save, Activity::default()).unwrap(), Some(Event::Saved { source: Some(0) })); + let mut reopened = Session::new(); + assert!(reopened.attach_preset_store(preset_store::open(Box::new(mem))).is_empty()); + assert_eq!(reopened.actions.list[0].icon, "TextColor"); + assert_eq!(reopened.actions.list[0].color, "yellow"); +} + #[test] fn untouched_new_page_keeps_one_default_and_leaves_without_publishing() { let (mut app, mem) = app(Default::default()); From 118e5215071bdddc5f1e8d2447c085a007e2ed2e Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:58:02 -0800 Subject: [PATCH 101/384] Keep disambiguated folder names stable after collision removal --- crates/engine/src/cmd/actions_cmds/library.rs | 14 +++++++++++++- .../src/cmd/actions_cmds/library_atn_tests.rs | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/actions_cmds/library.rs b/crates/engine/src/cmd/actions_cmds/library.rs index c8414698020..5f4a4e7d394 100644 --- a/crates/engine/src/cmd/actions_cmds/library.rs +++ b/crates/engine/src/cmd/actions_cmds/library.rs @@ -401,6 +401,16 @@ pub(crate) fn encode(state: &ActionState, keys: &[(String, String)]) -> Result = groups + .iter() + .map(|(name, selection)| match selection.raw.as_ref() { + Some(row) if row.logical_name == *name => Ok(row.file()?.name.to_string_lossy()), + _ => Ok(atn_cmds::wire_set_name(name).to_string()), + }) + .collect::>()?; + let mut available = + atn_cmds::UniqueNames::new(std::iter::once(atn_cmds::UNGROUPED_SET.to_string()).chain(wire_names.into_iter().filter(|name| !name.is_empty()))); + let literal_alias = available.claim(atn_cmds::UNGROUPED_SET); let mut names = std::collections::HashMap::new(); for action in &state.list { *names.entry(action.name.as_str()).or_insert(0usize) += 1; @@ -411,7 +421,9 @@ pub(crate) fn encode(state: &ActionState, keys: &[(String, String)]) -> Result>(), ["Named", "Collision"]); let loaded = library::decode(&encode(&session.actions)).unwrap(); assert_eq!(loaded.list.iter().map(|action| action.name.as_str()).collect::>(), ["Named", "Collision"]); + session.execute("actions.delete", json!({"set":"Ungrouped Actions 2"})).unwrap(); + let loaded = library::decode(&encode(&session.actions)).unwrap(); + assert_eq!(loaded.list[0].set.as_deref(), Some("Ungrouped Actions 3"), "removing the collision cannot change the surviving logical folder on reload"); } fn legacy() -> Vec { From a918da2c2d7059ed58005b8c29c5463d1e8b3fe7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 20:59:30 -0800 Subject: [PATCH 102/384] Correct alternate shortcut fixture types and format review regressions --- .../engine/tests/it/actions_editor_draft.rs | 4 +-- .../engine/tests/it/actions_group_delete.rs | 27 ++++++++++++------- crates/ui-egui/src/panels/actions.rs | 6 ++--- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/engine/tests/it/actions_editor_draft.rs b/crates/engine/tests/it/actions_editor_draft.rs index 0379fb6d4b9..7507d92af2d 100644 --- a/crates/engine/tests/it/actions_editor_draft.rs +++ b/crates/engine/tests/it/actions_editor_draft.rs @@ -47,7 +47,7 @@ fn namesake_rename_follows_only_the_first_resolved_occurrence_in_both_editors() s.actions.list.insert(1, s.actions.list[0].clone()); s.actions.list[2].steps.push(("actions.conditional".into(), json!({"if":"always","then":"Original","else":"Other"}))); s.prefs.edit(|prefs| { - prefs.alternate_shortcuts.insert("actions.play:Original".into(), "Shift+F8".into()); + prefs.alternate_shortcuts.insert("actions.play:Original".into(), vec!["Shift+F8".into()]); }); if modern { let draft = open(&mut s, json!({"action":index})); @@ -67,7 +67,7 @@ fn namesake_rename_follows_only_the_first_resolved_occurrence_in_both_editors() assert_eq!(caller.steps[1].1["else"], "Other"); let key = format!("actions.play:{owner}"); assert_eq!(s.prefs().shortcuts.get(&key).map(String::as_str), Some("F6")); - assert_eq!(s.prefs().alternate_shortcuts.get(&key).map(String::as_str), Some("Shift+F8")); + assert_eq!(s.prefs().alternate_shortcuts[&key], ["Shift+F8"]); let other = if index == 0 { "Original" } else { "Renamed" }; assert!(!s.prefs().shortcuts.contains_key(&format!("actions.play:{other}"))); assert!(!s.prefs().alternate_shortcuts.contains_key(&format!("actions.play:{other}"))); diff --git a/crates/engine/tests/it/actions_group_delete.rs b/crates/engine/tests/it/actions_group_delete.rs index 52dbaa5a266..7a30390955c 100644 --- a/crates/engine/tests/it/actions_group_delete.rs +++ b/crates/engine/tests/it/actions_group_delete.rs @@ -10,19 +10,23 @@ fn deleting_a_namesake_keeps_named_calls_and_keys_until_the_last_target_is_gone( for group in [false, true] { let mut s = Session::new(); s.actions.list = vec![ - grouped("Duplicate", "First"), grouped("Duplicate", "Second"), - Action::new("Caller", vec![ - ("actions.play".into(), json!({"action":"Duplicate"})), - ("actions.play".into(), json!({"action":0})), - ("actions.play".into(), json!({"action":1})), - ("actions.conditional".into(), json!({"if":"always","then":"Duplicate","else":0})), - ]), + grouped("Duplicate", "First"), + grouped("Duplicate", "Second"), + Action::new( + "Caller", + vec![ + ("actions.play".into(), json!({"action":"Duplicate"})), + ("actions.play".into(), json!({"action":0})), + ("actions.play".into(), json!({"action":1})), + ("actions.conditional".into(), json!({"if":"always","then":"Duplicate","else":0})), + ], + ), ]; s.prefs.edit(|prefs| { prefs.shortcuts.insert("actions.play:Duplicate".into(), "F5".into()); prefs.alternate_shortcuts.insert("actions.play:Duplicate".into(), vec!["F6".into()]); }); - s.execute("actions.delete", if group {json!({"set":"First"})} else {json!({"action":0})}).unwrap(); + s.execute("actions.delete", if group { json!({"set":"First"}) } else { json!({"action":0}) }).unwrap(); assert_eq!(s.actions.list[0].name, "Duplicate"); let calls = &s.actions.list[1].steps; assert_eq!(calls[0].1["action"], "Duplicate"); @@ -34,8 +38,11 @@ fn deleting_a_namesake_keeps_named_calls_and_keys_until_the_last_target_is_gone( assert_eq!(s.prefs().alternate_shortcuts["actions.play:Duplicate"], ["F6"]); s.execute("actions.delete", json!({"action":0})).unwrap(); for (id, params) in &s.actions.list[0].steps { - if id == "actions.play" { assert!(params["action"].is_null()); } - else { assert!(params["then"].is_null()); } + if id == "actions.play" { + assert!(params["action"].is_null()); + } else { + assert!(params["then"].is_null()); + } } assert!(!s.prefs().shortcuts.contains_key("actions.play:Duplicate")); assert!(!s.prefs().alternate_shortcuts.contains_key("actions.play:Duplicate")); diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index c4f4a9ab3ea..e7fd8a838e5 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -989,10 +989,8 @@ impl PausedPlaybacks { fn pause(&mut self, frame: Frame, dialog: u64, gate: Option, used: u64, doc: Option) -> Result { let token = self.last.checked_add(1).ok_or("action step dialog identities exhausted")?; self.last = token; - self.paused.insert( - token, - PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used, incremental: false, doc }, - ); + self.paused + .insert(token, PausedPlayback { frame, dialog, gate, record: None, parents: Vec::new(), then: Vec::new(), ran: 0, used, incremental: false, doc }); Ok(token) } From dbe2d24411234ea5104ee38584a99ee9d27d6d56 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 21:00:58 -0800 Subject: [PATCH 103/384] Build document views before exercising window-control ownership --- crates/ui-egui/src/panels/actions/owned_preview_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs index d81c8c1a8ad..ed637506435 100644 --- a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs +++ b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs @@ -39,7 +39,7 @@ fn blur_draft(app: &mut PhotocraftApp) -> u64 { fn generic_dialog_checks_the_document_after_the_real_window_control_route() { for document in [0, 1] { let mut app = app(); - app.session.execute("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("file.new", json!({"width":8,"height":6})).unwrap(); assert!(app.session.set_active(0)); let id = blur_draft(&mut app); preview_draft(&mut app, id, 0).unwrap(); From 6b38df86bcfe8406bbb920dc5bb255521d951bcd Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 22:11:16 -0800 Subject: [PATCH 104/384] Document transform hit-zone invariants and regression controls --- crates/ui-egui/src/canvas/transform_controls.rs | 2 ++ crates/ui-egui/src/tools/transform_tool/pointer.rs | 7 +++++++ crates/ui-egui/src/transform_hit_zone_tests.rs | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index 5a26a5a281e..76237b1e20d 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -58,10 +58,12 @@ pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Op Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32))) } +/// Express the unopened screen rectangle in the same corner order as a transform quad. fn controls_quad(r: Rect) -> [[f64; 2]; 4] { [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| [f64::from(p.x), f64::from(p.y)]) } +/// Classify screen coordinates with the fixed screen-space tolerance, without a pivot. fn controls_zone(r: Rect, p: Pos2) -> crate::transform_tool::Hit { crate::state::TransformSession::box_hit(&controls_quad(r), [f64::from(p.x), f64::from(p.y)], crate::transform_tool::HANDLE_PX) } diff --git a/crates/ui-egui/src/tools/transform_tool/pointer.rs b/crates/ui-egui/src/tools/transform_tool/pointer.rs index 735bec06587..d06f921574a 100644 --- a/crates/ui-egui/src/tools/transform_tool/pointer.rs +++ b/crates/ui-egui/src/tools/transform_tool/pointer.rs @@ -19,6 +19,9 @@ pub(crate) enum Hit { /// Screen-sized corner and outward edge bands, shared with Move's unopened controls. impl TransformSession { + /// Classify a document point with screen-sized corner and outward edge zones. + /// Corners outrank free edge bands; overlapping painted handles keep nearest-handle priority. + /// Only projection-scale rounding is treated as an edge on the interior side. pub(crate) fn box_hit(quad: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> Hit { // `tol` is the existing 12 pt handle tolerance, divided by the view zoom. let corner_radius = tol * (8.0 / HANDLE_PX); @@ -119,6 +122,8 @@ pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { if d(t.pivot) < tol && d(t.pivot) < handle_distance { Hit::Pivot } else { h } } +/// Even-odd quad membership, independent of winding and valid for concave boxes. +/// Handles and edge-rounding tolerance are classified separately by the caller. fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { let mut c = false; for i in 0..4 { @@ -314,6 +319,8 @@ pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64 rotate_dir(app.ui.transform.as_ref()?, app.transform_preview.as_ref(), p, handle_tolerance(app)) } +/// Choose hover or captured rotation feedback without overriding scaling gestures. +/// Warp, Distort, Skew and Content-Aware Scale keep their own cursor behavior. pub(super) fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { // Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow. if !(p[0].is_finite() && p[1].is_finite()) diff --git a/crates/ui-egui/src/transform_hit_zone_tests.rs b/crates/ui-egui/src/transform_hit_zone_tests.rs index 010d4e68e94..1f9b52ae1cb 100644 --- a/crates/ui-egui/src/transform_hit_zone_tests.rs +++ b/crates/ui-egui/src/transform_hit_zone_tests.rs @@ -43,6 +43,7 @@ fn near(actual: Option<[f64; 2]>, expected: [f64; 2]) { assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1e-6, "{actual:?} != {expected:?}"); } +/// Screen-sized zones must survive zoom changes; the interior control rejects an all-sided edge grab area. #[test] fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { for zoom in [1.0, 2.0] { @@ -73,6 +74,7 @@ fn whole_edge_and_corner_priority_table_at_100_and_200_percent() { } } +/// An overlapping corner zone must not steal a closer painted midpoint on a small box. #[test] fn small_boxes_keep_their_painted_mid_edge_handles() { for side in [8.0, 16.0, 32.0] { @@ -89,6 +91,7 @@ fn small_boxes_keep_their_painted_mid_edge_handles() { } } +/// Reproduce inward rounding through the view transform, with a one-pixel interior control. #[test] fn an_edge_survives_the_actual_f32_view_round_trip() { for zoom in [1.0_f32, 2.0] { @@ -103,6 +106,7 @@ fn an_edge_survives_the_actual_f32_view_round_trip() { } } +/// The same small-edge press must be ignored by Distort and stretch one axis in legacy unlinked scaling. #[test] fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { for mode in [TransformMode::Distort, TransformMode::Free] { @@ -131,6 +135,7 @@ fn a_small_edge_keeps_distort_and_legacy_aspect_behavior() { } } +/// At 45 degrees, corners and edges exchange cursor axis orientation at both tested zooms. #[test] fn turned_box_zones_and_resize_cursors_follow_the_quad() { let turn = |p: [f64; 2]| { @@ -158,6 +163,7 @@ fn turned_box_zones_and_resize_cursors_follow_the_quad() { } } +/// Skewed edges and reversed winding must use actual segment geometry for hits and turn direction. #[test] fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { let q = [[100.0, 100.0], [500.0, 140.0], [460.0, 300.0], [80.0, 260.0]]; @@ -171,6 +177,7 @@ fn distorted_and_flipped_quads_use_their_edges_not_an_axis_aligned_box() { } } +/// A concave quad whose corner mean is beyond an edge rejects center-based normal orientation. #[test] fn concave_quad_edge_direction_uses_winding_in_both_orientations() { let q = [[0.0, 0.0], [100.0, 0.0], [10.0, 10.0], [0.0, 100.0]]; @@ -179,6 +186,7 @@ fn concave_quad_edge_direction_uses_winding_in_both_orientations() { } } +/// Each outside position selects its inward turn orientation and hides the system cursor. #[test] fn outside_rotation_has_eight_edge_and_corner_orientations() { let app = app(QUAD, 1.0); @@ -199,6 +207,7 @@ fn outside_rotation_has_eight_edge_and_corner_orientations() { } } +/// Compare unopened controls with an active session at identical points, including a free interior. #[test] fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { let app = app(QUAD, 1.0); @@ -217,6 +226,7 @@ fn unopened_move_controls_share_the_open_sessions_zones_and_cursors() { assert!(!transform_controls_hit(r, r.center()), "inside remains an ordinary Move drag"); } +/// A press away from midpoint handles must scale an anchored preview without creating a history edit. #[test] fn move_whole_edge_press_enters_scaling_at_both_zooms() { for zoom in [1.0, 2.0] { @@ -250,6 +260,7 @@ fn move_whole_edge_press_enters_scaling_at_both_zooms() { } } +/// Excluded modes retain cursor ownership, and non-finite coordinates cannot activate controls. #[test] fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { let mut app = app(QUAD, 1.0); @@ -278,6 +289,7 @@ fn modes_that_do_not_rotate_and_invalid_points_do_not_hide_the_pointer() { } } +/// Captured rotation must retain feedback after crossing into the box, independent of hover zones. #[test] fn rotate_drag_keeps_the_arrow_when_the_pointer_enters_the_box() { let mut app = app(QUAD, 1.0); From c1d996fb9622c1e0f30d135029f4128d616d43a2 Mon Sep 17 00:00:00 2001 From: NaufalDzz Date: Wed, 4 Nov 2026 13:26:49 +0700 Subject: [PATCH 105/384] Find Layers: focus the Name field after the click on the menu item (#10884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Select › Find Layers ran the command in the frame of the click, so the Name field asked for the focus in a frame with a pointer press elsewhere and egui dropped it at the end of that frame. The focus now waits for a frame without a press or release. New tests click the real menu item, and close the Layers panel from Window › Layers before the shortcut. --- .../src/panels/layer_filter_ui/find.rs | 11 +++++-- .../src/panels/layer_filter_ui/find_tests.rs | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find.rs b/crates/ui-egui/src/panels/layer_filter_ui/find.rs index 964b76f0568..bfacef6f1e2 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/find.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/find.rs @@ -32,10 +32,17 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Re Ok(reply) } -/// The Name filter's text field, taking the focus with its text selected after [`invoke`]. +/// The Name filter's text field, taking the focus with its text selected after [`invoke`]. The +/// focus waits for a frame without a pointer press or release: egui drops the focus at the end of +/// a frame in which the pointer was clicked elsewhere, as it is when the menu item is clicked. pub fn name_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let id = ui.scope_id().with("layer-filter-name"); - if ui.ctx().data_mut(|d| d.remove_temp::(focus_flag())).unwrap_or(false) { + let pending = ui.ctx().data(|d| d.get_temp::(focus_flag())).unwrap_or(false); + let clicking = ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released() || i.pointer.any_down()); + if pending && clicking { + ui.ctx().request_repaint(); + } else if pending { + ui.ctx().data_mut(|d| d.remove_temp::(focus_flag())); ui.memory_mut(|m| m.request_focus(id)); widgets::field_tab::select_all(ui.ctx(), id, &app.ui.layer_filter_name); } diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs index ea1dccd5ab1..364bb1d6d15 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs @@ -97,3 +97,35 @@ fn the_shortcut_runs_find_layers() { assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); assert!(h.ctx.memory(|m| m.focused()).is_some(), "the caret is in the Name field"); } + +#[test] +fn clicking_the_menu_item_leaves_the_caret_in_the_name_field() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.get_by_label("Select").click(); + h.run_steps(4); + h.get_by_label("Find Layers Ctrl+Alt+Shift+F").click(); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("lin".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "lin", "typing goes to the Name field"); +} + +#[test] +fn the_shortcut_shows_a_layers_tab_closed_from_the_window_menu() { + let mut h = harness(); + use egui_kittest::kittest::Queryable; + h.get_by_label("Window").click(); + h.run_steps(4); + h.get_by_label("✔ Layers F7").click(); + h.run_steps(4); + assert!(!h.state().ui.panels.layers, "Window › Layers closed the panel"); + h.state_mut().ui.layer_filter_name = "lin".into(); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT, egui::Key::F); + h.run_steps(6); + assert_eq!(h.state().ui.layer_filter_by, FilterBy::Name); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the panel is back, focused, with its text selected"); +} From 34dc2c4819b91f050215afd6075cc2c91b09b9bc Mon Sep 17 00:00:00 2001 From: NaufalDzz Date: Wed, 4 Nov 2026 13:38:52 +0700 Subject: [PATCH 106/384] Find Layers: show a Layers panel closed in the panel docking layout (#10884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the panel docking layout on (any customized workspace), Window › Layers closes the Layers panel in that layout, which the dock group's reveal doesn't reopen. Find Layers now shows it through window.panel.activate there, as a new adjustment layer shows Properties. --- .../ui-egui/src/panels/layer_filter_ui/find.rs | 11 +++++++++-- .../src/panels/layer_filter_ui/find_tests.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find.rs b/crates/ui-egui/src/panels/layer_filter_ui/find.rs index bfacef6f1e2..941ebef92d5 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/find.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/find.rs @@ -25,8 +25,15 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, params: Value) -> Re app.ui.layer_filter_name = name.to_string(); } if !given { - *crate::dock::Group::Layers.tab_mut(&mut app.ui.dock_tabs) = 0; - crate::dock::reveal(app, crate::dock::Group::Layers); + // The panel docking layout (when on) shows the Layers panel itself, else the dock group. + if app.ui.docking.is_some() { + if let Some(shown) = crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": "layers"})) { + shown?; + } + } else { + *crate::dock::Group::Layers.tab_mut(&mut app.ui.dock_tabs) = 0; + crate::dock::reveal(app, crate::dock::Group::Layers); + } ctx.data_mut(|d| d.insert_temp(focus_flag(), true)); } Ok(reply) diff --git a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs index 364bb1d6d15..076d5038c88 100644 --- a/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs +++ b/crates/ui-egui/src/panels/layer_filter_ui/find_tests.rs @@ -129,3 +129,19 @@ fn the_shortcut_shows_a_layers_tab_closed_from_the_window_menu() { h.run_steps(4); assert_eq!(h.state().ui.layer_filter_name, "other", "the panel is back, focused, with its text selected"); } + +#[test] +fn the_menu_item_shows_a_layers_panel_closed_in_the_docking_layout() { + let mut h = harness(); + h.state_mut().run("window.panel.move", json!({"panel": "swatches", "anchor": "layers", "zone": "top"})).unwrap(); + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + h.run_steps(4); + let shown = |h: &Harness<'_, PhotocraftApp>| h.state().ui.docking.as_ref().is_some_and(|w| w.layout.contains(&"layers".to_string())); + assert!(!shown(&h), "the Layers panel is closed"); + h.state_mut().ui.layer_filter_name = "lin".into(); + find(&mut h, json!({})); + assert!(shown(&h), "Find Layers shows the Layers panel again"); + h.event(egui::Event::Text("other".into())); + h.run_steps(4); + assert_eq!(h.state().ui.layer_filter_name, "other", "the Name field has the focus with its text selected"); +} From 13abd9d9169791ccbe72d6ca54e114c113e08fd1 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 22:46:11 -0800 Subject: [PATCH 107/384] Declare PSD fixture dependency for CLI integration tests --- Cargo.lock | 1 + apps/photocraft-cli/Cargo.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ab0417b79b8..3428c184f37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5106,6 +5106,7 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-psd", "photocraft-raw", "serde_json", "thiserror 2.0.21", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index 94e04b518ad..c6be03640ff 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -34,6 +34,8 @@ thiserror = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] +# ATN fixtures for external Actions input limits. +photocraft-psd = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in From 5e748bfc0136bfd37dc0b76a6fa9a03d9c58970b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 23:33:30 -0800 Subject: [PATCH 108/384] Validate action definitions with the durable encoder before publication --- crates/engine/src/cmd/actions_cmds/editor.rs | 20 ++++++----- crates/engine/src/cmd/actions_cmds/library.rs | 23 ++++++++++++ crates/engine/src/cmd/actions_edit_cmds.rs | 19 +++++++--- crates/engine/tests/it/actions_duplicate.rs | 35 +++++++++++++++++++ .../engine/tests/it/actions_editor_draft.rs | 28 +++++++++++++++ 5 files changed, 112 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index d6f91fe30c6..718fc4d7bd9 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -420,7 +420,7 @@ fn rename_calls(params: &mut Value, id: &str, old: &str, new: &str) { } } -/// Only changed references need prepared copies; no library or Session snapshot is taken. +/// Prepare only changed named-call parameters; the caller validates the proposed definitions. fn references(s: &Session, draft: &Draft, value: &mut Action) -> Vec<(usize, usize, Value)> { let mut changes = Vec::new(); if !draft.source.is_some_and(|index| super::owns_name(&s.actions.list, index)) || draft.opening.name == value.name { @@ -520,20 +520,24 @@ fn save(s: &mut Session, p: &Value) -> Result { next.alternates = action_keys(&alternates); next.recording = None; let response = view(&next, &value); - if target == s.actions.list.len() { - s.actions.list.try_reserve(1).map_err(|_| bad(SAVE, "not enough memory for the action"))?; + let mut proposed = super::library::definition_copy(&s.actions); + if target == proposed.list.len() { + proposed.list.try_reserve(1).map_err(|_| bad(SAVE, "not enough memory for the action"))?; } - // All validation and copies are complete. No command execution or fallible work follows. + // Include renamed callers in the same encoded candidate as the edited action. for (action, step, params) in changes { - if let Some((_, old)) = s.actions.list.get_mut(action).and_then(|action| action.steps.get_mut(step)) { + if let Some((_, old)) = proposed.list.get_mut(action).and_then(|action| action.steps.get_mut(step)) { *old = params; } } - if target == s.actions.list.len() { - s.actions.list.push(value); - } else if let Some(old) = s.actions.list.get_mut(target) { + if target == proposed.list.len() { + proposed.list.push(value); + } else if let Some(old) = proposed.list.get_mut(target) { *old = value; } + super::library::check_definition(&proposed, &keys).map_err(|error| bad(SAVE, error))?; + // Encoding succeeded. Publish definitions without replacing the live capture/owner state. + s.actions.list = proposed.list; if changed { s.actions.touch(); } diff --git a/crates/engine/src/cmd/actions_cmds/library.rs b/crates/engine/src/cmd/actions_cmds/library.rs index 5f4a4e7d394..9e1ba51ece7 100644 --- a/crates/engine/src/cmd/actions_cmds/library.rs +++ b/crates/engine/src/cmd/actions_cmds/library.rs @@ -27,6 +27,26 @@ pub(crate) fn check_action_count<'a>(state: &ActionState, actions: impl IntoIter Ok(()) } +/// Copy only persisted definitions and row provenance for pre-publication encoding. +/// Recording, editor ownership and playback counters remain in the live state. +pub(crate) fn definition_copy(state: &ActionState) -> ActionState { + ActionState { + list: state.list.clone(), + library: state.library.clone(), + sets: state.sets.clone(), + atn_set_rows: state.atn_set_rows.clone(), + ..Default::default() + } +} + +/// Use the durable encoder's complete limits before publishing a proposed definition. +/// This checks eligibility, while backend I/O retains its ordinary warning/retry semantics. +pub(crate) fn check_definition(state: &ActionState, shortcuts: &std::collections::BTreeMap) -> Result<()> { + let keys: Vec<_> = + shortcuts.iter().filter_map(|(key, value)| key.strip_prefix(super::PLAY_SHORTCUT_PREFIX).map(|name| (name.to_string(), value.clone()))).collect(); + encode(state, &keys).map(|_| ()) +} + #[derive(Debug)] struct Archive { bytes: Arc<[u8]>, @@ -301,10 +321,13 @@ struct OutputAction { items: Vec>, } +/// Resolve a physical source record, rejecting stale row/action indices. fn original_action(origin: &ActionOrigin) -> Result<&AtnAction> { origin.row.file()?.actions.get(origin.action).ok_or_else(|| "missing original action".into()) } +/// Rebuild edited headers and steps while retaining provenance for unchanged event spans. +/// Function keys bind only unique action names; opaque source headers remain available. fn action_record(action: &Action, set: &str, keys: &[(String, String)], unique_name: bool) -> Result<(AtnAction, OutputAction)> { let origin = action.library_origin.as_ref(); let original = origin.map(original_action).transpose()?; diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index 041af53e068..14ff032e02e 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -140,8 +140,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { crate::actions_cmds::library::check_action_count(&s.actions, s.actions.list.iter().chain(std::iter::once(&copied))).map_err(|error| bad(cmd, error))?; let at = idx + 1; let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()}); - s.actions.list.insert(at, copied); - shift_action_calls(&mut s.actions.list, at, 1); + let mut proposed = crate::actions_cmds::library::definition_copy(&s.actions); + proposed.list.insert(at, copied); + shift_action_calls(&mut proposed.list, at, 1); + crate::actions_cmds::library::check_definition(&proposed, &s.prefs().shortcuts).map_err(|error| bad(cmd, error))?; + s.actions.list = proposed.list; s.actions.touch(); Ok(result) } @@ -179,11 +182,17 @@ fn duplicate_set(s: &mut Session, p: &Value) -> Result { let copied: Vec<_> = added.iter().map(|a| a.name.clone()).collect(); let count = added.len(); crate::actions_cmds::library::check_action_count(&s.actions, s.actions.list.iter().chain(added.iter())).map_err(|error| bad(cmd, error))?; - s.actions.list.splice(at..at, added); + let mut proposed = crate::actions_cmds::library::definition_copy(&s.actions); + proposed.list.splice(at..at, added); if count != 0 { - shift_action_calls(&mut s.actions.list, at, count); + shift_action_calls(&mut proposed.list, at, count); } - crate::atn_cmds::sidecar::duplicate_set_rows(&mut s.actions, source, &name); + crate::atn_cmds::sidecar::duplicate_set_rows(&mut proposed, source, &name); + crate::actions_cmds::library::check_definition(&proposed, &s.prefs().shortcuts).map_err(|error| bad(cmd, error))?; + s.actions.list = proposed.list; + s.actions.sets = proposed.sets; + s.actions.library = proposed.library; + s.actions.atn_set_rows = proposed.atn_set_rows; s.actions.touch(); Ok(json!({"set":name,"actions":copied,"index":at})) } diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs index 6ecc6632eb3..9307e9e1311 100644 --- a/crates/engine/tests/it/actions_duplicate.rs +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -35,6 +35,41 @@ fn session() -> Session { s } +/// Whole-action and folder copies use the same encoder budget as durable storage. +#[test] +fn duplicate_rejects_combined_metadata_size_without_changing_rows_or_store() { + use photocraft_engine::preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}; + for set in [false, true] { + for admitted in [true, false] { + let store = MemBackend::default(); + let mut s = Session::new(); + assert!(s.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + let mut source = Action::new("Source", vec![("select.all".into(), json!({}))]); + source.set = Some("Group".into()); + source.description = "x".repeat(if admitted { 3 << 20 } else { 5 << 20 }); + s.actions.list.push(source); + s.execute("actions.dialog", json!({"action":0,"enabled":false})).unwrap(); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let before = s.actions.clone(); + let prefs = s.prefs().clone(); + let result = s.execute("actions.duplicate", if set { json!({"set":"Group"}) } else { json!({"action":0}) }); + if admitted { + result.unwrap(); + let mut restored = Session::new(); + assert!(restored.attach_preset_store(preset_store::open(Box::new(store))).is_empty()); + assert_eq!(restored.actions.list.len(), 2); + assert!(restored.actions.list.iter().all(|action| action.description.len() == 3 << 20)); + } else { + let error = result.unwrap_err(); + assert!(error.to_string().contains("byte limit"), "{error}"); + assert_eq!(s.actions, before); + assert_eq!(s.prefs(), &prefs); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + } + } + } +} + #[test] fn action_count_admission_reserves_the_metadata_carrier_before_publication() { for metadata in [false, true] { diff --git a/crates/engine/tests/it/actions_editor_draft.rs b/crates/engine/tests/it/actions_editor_draft.rs index 7507d92af2d..3720a797f44 100644 --- a/crates/engine/tests/it/actions_editor_draft.rs +++ b/crates/engine/tests/it/actions_editor_draft.rs @@ -39,6 +39,34 @@ fn open(s: &mut Session, params: Value) -> u64 { s.execute("actions.editor.open", params).unwrap()["draft"].as_u64().unwrap() } +/// An encoder refusal retains the dirty capture and both in-memory and stored definitions. +#[test] +fn save_rejects_unencodable_metadata_before_ending_capture_or_publishing() { + let (mut s, store) = session(); + let draft = open(&mut s, json!({"action":"Original"})); + editor(&mut s, "update", draft, json!({"name":"Renamed","description":"x".repeat(9 << 20),"shortcut":"F8"})).unwrap(); + editor(&mut s, "record", draft, json!({})).unwrap(); + let before = s.actions.clone(); + let prefs = s.prefs().clone(); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let error = editor(&mut s, "save", draft, json!({})).unwrap_err(); + assert!(error.to_string().contains("byte limit"), "{error}"); + assert_eq!(s.actions, before); + assert_eq!(s.prefs(), &prefs); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + assert!(editor(&mut s, "get", draft, json!({})).unwrap()["dirty"].as_bool().unwrap()); + + // A large eligible replacement can still close the same capture and persist the rename. + editor(&mut s, "update", draft, json!({"description":"x".repeat(3 << 20)})).unwrap(); + editor(&mut s, "save", draft, json!({})).unwrap(); + assert!(!editor(&mut s, "get", draft, json!({})).unwrap()["dirty"].as_bool().unwrap()); + let mut restored = Session::new(); + assert!(restored.attach_preset_store(preset_store::open(Box::new(store))).is_empty()); + assert_eq!(restored.actions.list[0].name, "Renamed"); + assert_eq!(restored.actions.list[0].description.len(), 3 << 20); + assert_eq!(restored.actions.list[1].steps[0].1["action"], "Renamed"); +} + #[test] fn namesake_rename_follows_only_the_first_resolved_occurrence_in_both_editors() { for modern in [false, true] { From 61057eb740cbb70d929c4eb48faf0de83501737c Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 23:33:31 -0800 Subject: [PATCH 109/384] Document Actions sidecar ordering and migration transaction ownership --- apps/photocraft-web/src/indexed_presets/migration.rs | 5 +++++ apps/photocraft-web/src/preset_bridge.rs | 4 ++++ crates/engine/src/cmd/atn_cmds/sidecar.rs | 10 ++++++++++ 3 files changed, 19 insertions(+) diff --git a/apps/photocraft-web/src/indexed_presets/migration.rs b/apps/photocraft-web/src/indexed_presets/migration.rs index 2ea0f5e9a36..19923579674 100644 --- a/apps/photocraft-web/src/indexed_presets/migration.rs +++ b/apps/photocraft-web/src/indexed_presets/migration.rs @@ -35,6 +35,7 @@ impl Drop for Scope { } } +/// Replace the single liveness request and detach its predecessor's success handler. fn next(store: &web_sys::IdbObjectStore, current: &Rc>>, callback: &Callback) -> Result<(), String> { let request = store.count().map_err(js)?; let borrowed = callback.borrow(); @@ -46,6 +47,7 @@ fn next(store: &web_sys::IdbObjectStore, current: &Rc Ok(()) } +/// Compare bounded Blob bytes before the shared deadline; only undefined means absent. async fn bytes(value: JsValue, deadline: f64) -> Result>, String> { if value.is_undefined() { return Ok(None); @@ -58,6 +60,9 @@ async fn bytes(value: JsValue, deadline: f64) -> Result>, String> Ok(Some(Uint8Array::new(&buffer).to_vec())) } +/// Compare the exact migration tuple inside one live read/write transaction. +/// Blob futures publish decisions; active request callbacks alone queue mutations. +/// Dropping the scope detaches the callback so late Blob results cannot write. pub(super) fn start(tx: &web_sys::IdbTransaction, store: &web_sys::IdbObjectStore, batch: &Batch, deadline: f64) -> Result { let migration = batch.migration.clone().ok_or("missing migration identity")?; let mut reads = Vec::new(); diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index 40f030f24b4..4f542e98f14 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -112,6 +112,8 @@ impl Bridge { Self::insert(&mut self.state(), name, &bytes) } + /// Take one pending batch, keeping the original migration bytes fixed across retries. + /// Newer live ATN edits stay dirty until that migration is acknowledged. pub fn begin(&self) -> Option { let mut s = self.state(); if s.writing || s.error.is_some() || s.dirty.is_empty() { @@ -138,6 +140,8 @@ impl Bridge { Some(Batch { files, migration: s.migration.clone() }) } + /// Apply a transaction completion result without clearing edits staged while it ran. + /// Failure requeues the batch; only success retires the migration identity. pub fn finish(&self, batch: Batch, result: Result<(), String>) { let mut s = self.state(); s.writing = false; diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs index 5ee23bb96ee..feefec560ed 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -343,6 +343,8 @@ pub(crate) fn library_groups(state: &ActionState) -> Result(state: &'a ActionState, only: Option<&str>, durable: bool) -> Result)>> { if state.list.len() > atn::MAX_TOTAL_ACTIONS || state.sets.len() > atn::MAX_SETS { return Err("action library exceeds archive limits".into()); @@ -535,14 +537,18 @@ pub(crate) fn legacy_origin(source: Option<&Source>) -> Result Result { decode_inner(files, false, None) } +/// Read the complete persisted sidecar, retaining raw outer fields for later edits. pub(crate) fn decode_library(files: &[AtnFile], raw_outer: Option<&[u8]>) -> Result { decode_inner(files, true, raw_outer) } +/// Bind one final sidecar to exact physical set/action positions. +/// Ambiguous wrappers or malformed bindings fail instead of applying metadata by name. fn decode_inner(files: &[AtnFile], library: bool, raw_outer: Option<&[u8]>) -> Result { let mut result = Decoded { hidden: None, sets: vec![None; files.len()], actions: files.iter().map(|file| vec![None; file.actions.len()]).collect(), source: None }; @@ -704,6 +710,8 @@ pub(super) fn append(files: &mut Vec, selected: &[Selection<'_>]) -> Re append_inner(files, selected, false, None, None).map(|_| ()) } +/// Encode the complete library carrier, including an explicit empty marker when needed. +/// Only full-library writes receive a legacy migration receipt. pub(crate) fn append_library( files: &mut Vec, selected: &[Selection<'_>], @@ -718,6 +726,8 @@ pub(crate) fn action_needs_metadata(action: &Action) -> bool { action.atn_metadata.is_some() || !action.description.is_empty() || !action.icon.is_empty() } +/// Patch selected positional metadata while preserving unknown source fields. +/// Reserve the carrier's real action/item budget before appending the final wrapper. fn append_inner( files: &mut Vec, selected: &[Selection<'_>], From ad5c259667c68dd662792be603240bb01b94d729 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 00:19:46 -0800 Subject: [PATCH 110/384] Use white glyphs on coloured Actions thumbnails --- crates/ui-egui/src/panels/actions/editor_picker.rs | 11 +++++++++-- .../ui-egui/src/panels/actions/editor_picker_tests.rs | 9 ++++++++- crates/ui-egui/src/panels/actions/modern.rs | 2 +- crates/ui-egui/src/theme.rs | 7 +++++-- crates/ui-egui/src/theme/tests.rs | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/actions/editor_picker.rs b/crates/ui-egui/src/panels/actions/editor_picker.rs index 766a92b9a51..63a9dd6f963 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker.rs @@ -68,13 +68,20 @@ const COLOR_GRID: [(&str, &str); 12] = [ fn thumbnail_style(color: &str, swatches: &[Color32; 12], tokens: &Tokens) -> (Color32, Color32) { let index = BUTTON_COLORS.iter().position(|name| *name == color).filter(|index| *index > 0); match index.and_then(|index| swatches.get(index)).copied() { - Some(fill) => (fill, Tokens::action_thumbnail_ink(fill)), + Some(fill) => (fill, Tokens::ACTION_THUMBNAIL_GLYPH), None => (tokens.card, tokens.text), } } +/// Selection marks remain legible independently of the fixed action-glyph ink. +fn swatch_style(color: &str, swatches: &[Color32; 12], tokens: &Tokens) -> (Color32, Color32) { + let (fill, glyph) = thumbnail_style(color, swatches, tokens); + let coloured = BUTTON_COLORS.iter().skip(1).any(|name| *name == color); + (fill, if coloured { Tokens::action_thumbnail_ink(fill) } else { glyph }) +} + fn color_button(ui: &mut egui::Ui, color: &str, label: &str, selected: bool, swatches: &[Color32; 12], tokens: &Tokens) -> egui::Response { - let (fill, ink) = thumbnail_style(color, swatches, tokens); + let (fill, ink) = swatch_style(color, swatches, tokens); let style = IconButtonStyle { normal: ButtonVisuals { fill, stroke: Stroke::new(1.0, tokens.field_border), icon: ink }, hovered: ButtonVisuals { fill, stroke: Stroke::new(1.0, tokens.text), icon: ink }, diff --git a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs index efbb7765a48..654f247af5a 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs @@ -177,14 +177,21 @@ fn two_by_six_colour_grid_keeps_classic_blue_and_new_blue_distinct() { #[test] fn thumbnail_uses_the_callers_indexed_palette_and_theme_neutral_fallback() { let palette = swatches(); - for kind in [ThemeKind::Pro, ThemeKind::ProLight] { + for kind in ThemeKind::ALL { let tokens = Tokens::for_kind(kind); for unknown_or_none in ["", UNKNOWN_COLOR, "future-color", "Blue", "blue2 "] { assert_eq!(thumbnail_style(unknown_or_none, &palette, &tokens), (tokens.card, tokens.text)); + assert_eq!(super::swatch_style(unknown_or_none, &palette, &tokens), (tokens.card, tokens.text)); } assert_eq!(thumbnail_style("blue", &palette, &tokens).0, palette[5]); assert_eq!(thumbnail_style("blue2", &palette, &tokens).0, palette[9]); assert_ne!(palette[5], palette[9]); + let actual = tokens.action_swatches(); + for (index, name) in photocraft_engine::actions_cmds::BUTTON_COLORS.iter().enumerate().skip(1) { + assert_eq!(thumbnail_style(name, &actual, &tokens), (actual[index], Color32::WHITE), "{kind:?}: {name}"); + assert_eq!(super::swatch_style(name, &actual, &tokens), (actual[index], Tokens::action_thumbnail_ink(actual[index]))); + } + assert_eq!(super::swatch_style("yellow", &actual, &tokens).1, Color32::BLACK); } assert_eq!(super::display_glyph(UNKNOWN_ICON), "square"); assert_eq!(super::display_glyph(""), "square"); diff --git a/crates/ui-egui/src/panels/actions/modern.rs b/crates/ui-egui/src/panels/actions/modern.rs index 75f5d84e9e9..8bc45ea322a 100644 --- a/crates/ui-egui/src/panels/actions/modern.rs +++ b/crates/ui-egui/src/panels/actions/modern.rs @@ -334,7 +334,7 @@ fn thumbnail(ui: &mut egui::Ui, action: &Action, size: f32, tokens: &Tokens) -> let fill = color.and_then(|index| tokens.action_swatches().get(index).copied()).unwrap_or(tokens.card); ui.painter().rect_filled(rect, tokens.radius_sm, fill); let glyph = if action.icon.is_empty() { "action-editor-click" } else { editor_glyphs::display_glyph(&action.icon) }; - icons::paint(ui, rect, glyph, size * 0.6, if color.is_some() { Tokens::action_thumbnail_ink(fill) } else { tokens.text }); + icons::paint(ui, rect, glyph, size * 0.6, if color.is_some() { Tokens::ACTION_THUMBNAIL_GLYPH } else { tokens.text }); response } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 73df15bf75a..cbb8a5d0905 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -214,8 +214,11 @@ pub struct Tokens { } impl Tokens { - /// Actions use both light and dark swatches, including the theme's dim text colour. - /// Choose the ink with the greater WCAG contrast against the actual supplied fill. + /// Coloured Actions thumbnails use a white glyph on the chosen colour. + pub const ACTION_THUMBNAIL_GLYPH: Color32 = Color32::WHITE; + + /// Swatch selection marks choose the greater WCAG contrast against the supplied fill. + /// Action glyphs use ACTION_THUMBNAIL_GLYPH instead. pub fn action_thumbnail_ink(fill: Color32) -> Color32 { let linear = |channel: u8| { let value = f32::from(channel) / 255.0; diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index 149666444b1..fb2cc2111f0 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -1,7 +1,7 @@ use super::*; #[test] -fn action_thumbnail_glyphs_remain_readable_on_every_theme_swatch() { +fn action_swatch_selection_marks_remain_readable_on_every_theme_swatch() { for kind in ThemeKind::ALL { for fill in Tokens::for_kind(kind).action_swatches().into_iter().skip(1) { let ink = Tokens::action_thumbnail_ink(fill); From c7d2332948c0ed86acae7710dbba423b1e3e1530 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 02:24:12 -0800 Subject: [PATCH 111/384] Keep unowned library playback valid across a later recording take --- crates/ui-egui/src/panels/actions.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index e7fd8a838e5..b6c326e121f 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -252,7 +252,9 @@ impl Frame { } fn validate(&self, session: &photocraft_engine::Session) -> Result<(), String> { - if self.take != session.actions.take { + // A plain library pause does not own a recorder started while its dialog waits. + // Suspended captures also validate their exact take through playback::Owner. + if matches!(self.source, Source::Draft(_)) && self.take != session.actions.take { return Err("this playback belongs to an earlier recording take".into()); } if matches!(self.source, Source::Library) && !session.actions.list.iter().any(|a| a == self.action.as_ref()) { From 0f6bbec672121c01cebc08f731116238775d0b50 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 02:28:12 -0800 Subject: [PATCH 112/384] Retain child results when nested shell playback fails --- crates/ui-egui/src/panels/actions.rs | 5 ++++- crates/ui-egui/src/panels/actions/tests.rs | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index b6c326e121f..befd1e8f0dd 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -476,7 +476,10 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ match result { Ok(value) => { if let Some(error) = actions_cmds::nested_failure(id, &value).or_else(|| actions_cmds::save_target_closed(&app.session, id, target)) { - failed = Some(json!({"step": i, "id": id, "error": error})); + // Keep the same child-result detail as headless Actions and Scripts. + let mut failure = json!({"step": i, "id": id, "error": error}); + failure["result"] = value; + failed = Some(failure); break; } if let Some(inner) = value.get("paused").filter(|p| p.is_object()) { diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index dda67cc0001..b0d006305f7 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -456,6 +456,10 @@ fn nested_shell_actions_include_view_steps_and_propagate_failures() { Some(|id, _| if id == "view.fitOnScreen" { Err(photocraft_engine::EngineError::Other("denied nested view".into())) } else { Ok(()) }); let r = app.run("actions.play", json!({"action":"Parent"})).unwrap(); assert!(r["failed"]["error"].as_str().unwrap().contains("denied nested view")); + let child = &r["failed"]["result"]; + assert_eq!(child["action"], "Child"); + assert_eq!(child["failed"]["id"], "view.fitOnScreen"); + assert!(child["failed"]["error"].as_str().unwrap().contains("denied nested view")); assert!(app.session.actions.playback_stack.is_empty()); assert_eq!(app.session.actions.playing, 0); } From 6c512133793004e346cc315a69f89d66cb9525cf Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 03:53:07 -0800 Subject: [PATCH 113/384] Fix Actions review findings and current CI contracts --- apps/photocraft-cli/src/actions.rs | 26 ++++-- apps/photocraft-cli/tests/it/cli.rs | 81 +++++++++++++++++++ crates/engine/src/cmd/actions_cmds.rs | 3 +- crates/engine/src/cmd/actions_cmds/editor.rs | 6 ++ crates/engine/src/cmd/atn_cmds/files.rs | 8 +- crates/engine/tests/it/actions_duplicate.rs | 2 +- .../engine/tests/it/actions_editor_draft.rs | 38 +++++++++ crates/engine/tests/it/atn_call_scopes.rs | 77 ++++++++++++++++++ .../tests/it/atn_crystallize_cell_size.rs | 39 +++++---- crates/engine/tests/it/atn_layer_style.rs | 5 +- crates/engine/tests/it/atn_ripple_amount.rs | 37 +++++---- crates/engine/tests/it/atn_shear.rs | 5 +- crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/dialogs/discard_ui.rs | 5 +- .../ui-egui/src/dialogs/discard_ui/tests.rs | 32 ++++++++ crates/ui-egui/src/panels/actions.rs | 43 +++++----- crates/ui-egui/src/panels/actions/editor.rs | 19 +++-- .../src/panels/actions/editor_glyph_tests.rs | 3 +- .../src/panels/actions/editor_picker.rs | 6 +- .../src/panels/actions/editor_picker_tests.rs | 6 +- .../src/panels/actions/editor_tests.rs | 2 +- crates/ui-egui/src/panels/actions/modern.rs | 23 +++--- 22 files changed, 365 insertions(+), 102 deletions(-) create mode 100644 crates/engine/tests/it/atn_call_scopes.rs diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 8341972c2d9..a17ca7586df 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -33,6 +33,8 @@ pub enum ActionsError { Atn { path: PathBuf, message: String }, #[error("no action named `{name}` in the set `{set}` (it holds {})", quoted(names))] NoSuchAtnAction { name: String, set: String, names: Vec }, + #[error("action name `{name}` is ambiguous across sets ({}); choose a unique action name", quoted(sets))] + AmbiguousAtnAction { name: String, sets: Vec }, #[error("{}: the set holds no actions", path.display())] EmptyAtn { path: PathBuf }, #[error("the set `{set}` holds {} actions: pick one with --action ({})", names.len(), quoted(names))] @@ -73,7 +75,11 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec Vec::new(), + Some(called) => serde_json::from_value(called.clone()).map_err(ActionsError::Set)?, + }; + Ok((steps_of(&v)?, called)) } /// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by @@ -105,11 +111,19 @@ pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec< } let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; let action = match (name, imp.actions.as_slice()) { - (Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction { - name: n.to_owned(), - set: imp.set.clone(), - names: names(&imp.actions), - })?, + (Some(n), _) => { + let matched: Vec<_> = imp.actions.iter().filter(|action| action.name == n).collect(); + match matched.as_slice() { + [only] => *only, + [] => return Err(ActionsError::NoSuchAtnAction { name: n.to_owned(), set: imp.set.clone(), names: names(&imp.actions) }), + _ => { + return Err(ActionsError::AmbiguousAtnAction { + name: n.to_owned(), + sets: matched.iter().map(|action| action.set.clone().unwrap_or_else(|| imp.set.clone())).collect(), + }); + } + } + } (None, [only]) => only, (None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }), (None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }), diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 6333eb575a3..5ec18717fd7 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1737,6 +1737,87 @@ fn tiff_options_choose_compression_transparency_and_pyramid() { } } +/// A generated droplet remains standalone when its top-level step calls a saved action. +#[test] +fn batch_loads_generated_droplet_called_actions_and_rejects_malformed_dependencies() { + use photocraft_engine::{Session, actions_cmds::Action}; + let d = tmp("droplet-called-actions"); + let input_dir = d.join("in"); + std::fs::create_dir(&input_dir).unwrap(); + let input = input_dir.join("input.png"); + write_png(&input, 4, 3, 17); + let droplet = d.join("nested.pcdroplet"); + let output = d.join("out"); + let mut session = Session::new(); + session.actions.list.push(Action::new("Invert", vec![("image.adjustments.invert".into(), json!({}))])); + session.execute("file.automate.createDroplet", json!({"path":droplet,"steps":[["actions.play",{"action":"Invert"}]],"shim":false})).unwrap(); + let (steps, called, warnings) = photocraft_cli::load_actions_file(&droplet, None).unwrap(); + assert_eq!(steps[0].0, "actions.play"); + assert_eq!(called.len(), 1); + assert_eq!(called[0].name, "Invert"); + assert!(warnings.is_empty()); + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let args = ["batch".as_ref(), "--in".as_ref(), input_dir.as_os_str(), "--out".as_ref(), output.as_os_str(), "--actions".as_ref(), droplet.as_os_str()]; + assert_eq!(photocraft_cli::run(args, &mut stdout, &mut stderr), 0, "{}", String::from_utf8_lossy(&stderr)); + assert!(output.join("input.png").exists()); + let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&droplet).unwrap()).unwrap(); + value["calledActions"] = json!({"not":"an action list"}); + std::fs::write(&droplet, serde_json::to_vec(&value).unwrap()).unwrap(); + assert!(photocraft_cli::load_actions_file(&droplet, None).is_err()); + value.as_object_mut().unwrap().remove("calledActions"); + value["action"]["steps"] = json!([["image.adjustments.invert", {}]]); + let (_, called) = photocraft_cli::load_actions(&value.to_string(), None).unwrap(); + assert!(called.is_empty(), "legacy droplets with no dependency table still load"); +} + +/// Explicit names must select exactly one action before Batch creates any output. +#[test] +fn batch_rejects_ambiguous_atn_action_names_and_keeps_unique_selection() { + use photocraft_psd::{ + atn::{self, AtnAction, AtnFile}, + descriptor::UnicodeString, + }; + let d = tmp("ambiguous-atn-action"); + let file = d.join("sets.atn"); + let files = [ + AtnFile { + name: UnicodeString::new_nul("Group A"), + actions: vec![ + AtnAction { name: UnicodeString::new_nul("Resize"), ..Default::default() }, + AtnAction { name: UnicodeString::new_nul("Only"), ..Default::default() }, + ], + ..Default::default() + }, + AtnFile { + name: UnicodeString::new_nul("Group B"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Resize"), ..Default::default() }], + ..Default::default() + }, + ]; + std::fs::write(&file, atn::write_sets(&files).unwrap()).unwrap(); + let error = photocraft_cli::load_actions_file(&file, Some("Resize")).unwrap_err().to_string(); + assert!(error.contains("ambiguous") && error.contains("Group A") && error.contains("Group B"), "{error}"); + let (steps, actions, _) = photocraft_cli::load_actions_file(&file, Some("Only")).unwrap(); + assert_eq!(steps, [("actions.play".into(), json!({"action":"Only"}))]); + assert_eq!(actions.len(), 3); + let output = d.join("out"); + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let args = [ + "batch".as_ref(), + "--in".as_ref(), + d.as_os_str(), + "--out".as_ref(), + output.as_os_str(), + "--actions".as_ref(), + file.as_os_str(), + "--action".as_ref(), + "Resize".as_ref(), + ]; + assert_eq!(photocraft_cli::run(args, &mut stdout, &mut stderr), 1); + assert!(String::from_utf8_lossy(&stderr).contains("ambiguous")); + assert!(!output.exists()); +} + #[test] fn batch_rejects_a_legacy_json_library_before_creating_output() { let d = tmp("legacy-action-library-rejected"); diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 662bf128f61..de2c235bb57 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -389,7 +389,8 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> { } let idx = resolve(&s.actions.list, p, "actions.play")?; let action = s.actions.list.get(idx).cloned().ok_or_else(|| bad("actions.play", "no such action"))?; - if s.actions.playback_stack.iter().any(|name| name == &action.name) || s.actions.recording.is_some_and(|(_, i)| i == idx) { + if s.actions.playback_stack.iter().any(|name| name == &action.name) || s.actions.recording.is_some_and(|(_, i)| i == idx) || editor::recording_into(s, idx) + { return Err(bad("actions.play", format!("recursive action call to `{}`", action.name))); } if s.actions.playback_stack.len() >= 16 { diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index 718fc4d7bd9..02d6a5de975 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -36,6 +36,12 @@ impl Draft { } } +/// A recorded library call may not target the definition being edited. +/// Compare the resolved row, so a different same-name occurrence remains callable. +pub(super) fn recording_into(s: &Session, index: usize) -> bool { + s.actions.editor.as_ref().is_some_and(|draft| draft.recording.is_some() && draft.source == Some(index)) +} + /// Only action bindings participate in the editor's shortcut stale guard. fn action_keys(keys: &BTreeMap) -> BTreeMap { keys.iter().filter(|(key, _)| key.starts_with(super::PLAY_SHORTCUT_PREFIX)).map(|(key, value)| (key.clone(), value.clone())).collect() diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index a07a809feec..754dd431417 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -139,7 +139,13 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { }; for key in keys { if let Some(target) = params.get(*key).and_then(Value::as_str) { - let to = if let Some(to) = local.get(target) { + let scope = params + .get("set") + .and_then(Value::as_str) + .and_then(|name| imp.sets.iter().position(|set| set.name == name)) + .and_then(|index| renamed.get(index)) + .unwrap_or(local); + let to = if let Some(to) = scope.get(target) { Some(to) } else { match global.get(target) { diff --git a/crates/engine/tests/it/actions_duplicate.rs b/crates/engine/tests/it/actions_duplicate.rs index 9307e9e1311..420fd620317 100644 --- a/crates/engine/tests/it/actions_duplicate.rs +++ b/crates/engine/tests/it/actions_duplicate.rs @@ -76,7 +76,7 @@ fn action_count_admission_reserves_the_metadata_carrier_before_publication() { for operation in ["action", "set", "editor"] { let mut s = Session::new(); let capacity = photocraft_psd::atn::MAX_TOTAL_ACTIONS - usize::from(metadata); - s.actions.list = (0..capacity - 1).map(|index| Action::new(&format!("Action {index}"), vec![])).collect(); + s.actions.list = (0..capacity - 1).map(|index| Action::new(format!("Action {index}"), vec![])).collect(); s.actions.list[0].set = Some("Source".into()); if metadata { s.actions.list[0].description = "Requires the hidden metadata action".into(); diff --git a/crates/engine/tests/it/actions_editor_draft.rs b/crates/engine/tests/it/actions_editor_draft.rs index 3720a797f44..6d84097ea9a 100644 --- a/crates/engine/tests/it/actions_editor_draft.rs +++ b/crates/engine/tests/it/actions_editor_draft.rs @@ -39,6 +39,44 @@ fn open(s: &mut Session, params: Value) -> u64 { s.execute("actions.editor.open", params).unwrap()["draft"].as_u64().unwrap() } +/// All library-play entry points reject the edited row before suspending its draft capture. +#[test] +fn recorded_self_calls_use_definition_identity_before_any_playback_effect() { + for route in ["engine", "shell", "incremental"] { + for target in [json!("Original"), json!(0)] { + let (mut s, store) = session(); + s.actions.list[0].steps.push(("layer.new.layer".into(), json!({"name":"Must not execute"}))); + let draft = open(&mut s, json!({"action":0})); + editor(&mut s, "update", draft, json!({"name":"Renamed"})).unwrap(); + editor(&mut s, "record", draft, json!({})).unwrap(); + let actions = s.actions.clone(); + let layers = s.active().unwrap().doc.layers.len(); + let journal = s.journal.len(); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let params = json!({"action":target}); + let result = match route { + "engine" => s.execute("actions.play", params).map(|_| ()), + "shell" => photocraft_engine::actions_cmds::playback::library(&mut s, ¶ms).map(|_| ()), + _ => photocraft_engine::actions_cmds::playback::Owner::library(&mut s, ¶ms).map(|_| ()), + }; + assert!(result.unwrap_err().to_string().contains("recursive action call"), "{route}"); + assert_eq!(s.actions, actions); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); + assert_eq!(s.journal.len(), journal); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + } + } + // The first namesake is a distinct definition when this draft edits the second row. + let (mut s, _) = session(); + s.actions.list.insert(1, s.actions.list[0].clone()); + let draft = open(&mut s, json!({"action":1})); + editor(&mut s, "record", draft, json!({})).unwrap(); + s.execute("actions.play", json!({"action":0})).unwrap(); + editor(&mut s, "stop", draft, json!({})).unwrap(); + editor(&mut s, "save", draft, json!({})).unwrap(); + assert_eq!(s.actions.list[1].steps.last().unwrap(), &("actions.play".into(), json!({"action":"Original"}))); +} + /// An encoder refusal retains the dirty capture and both in-memory and stored definitions. #[test] fn save_rejects_unencodable_metadata_before_ending_capture_or_publishing() { diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs new file mode 100644 index 00000000000..60cc11da01f --- /dev/null +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -0,0 +1,77 @@ +//! Import resolves explicit set names before normalizing duplicate action names. +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnFile}, + descriptor::UnicodeString, +}; +use serde_json::json; + +/// Each call to the public exporter supplies one physical row, including repeated names. +fn row(name: &str, actions: Vec) -> AtnFile { + if actions.is_empty() { + return AtnFile { name: UnicodeString::new_nul(name), ..Default::default() }; + } + let refs: Vec<_> = actions.iter().collect(); + let (bytes, skipped) = atn_cmds::export(name, &refs, &[], false).unwrap(); + assert!(skipped.is_empty()); + atn::parse(&bytes).unwrap() +} + +fn finish(marker: &str) -> Action { + Action::new("Finish", vec![("layer.new.layer".into(), json!({"name":marker}))]) +} + +/// Play and Conditional use the first named physical set, retaining legacy fallback rules. +#[test] +fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming() { + for conditional in [false, true] { + for local in [false, true] { + for scope in [Some("B"), None, Some("Missing"), Some("Empty")] { + let mut params = if conditional { json!({"if":"always","then":"Finish"}) } else { json!({"action":"Finish"}) }; + if let Some(scope) = scope { + params["set"] = json!(scope); + } + let command = if conditional { "actions.conditional" } else { "actions.play" }; + let mut caller = vec![Action::new("Caller", vec![(command.into(), params)])]; + if local { + caller.push(finish("From C")); + } + let files = vec![ + row("A", vec![finish("From A")]), + row("B", vec![finish("From B first")]), + row("B", vec![finish("From B second")]), + row("C", caller), + row("Empty", Vec::new()), + ]; + let bytes = atn::write_sets(&files).unwrap(); + let mut session = Session::new(); + session.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = session.actions.clone(); + let result = session.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes),"replace":false})); + let expected = if scope == Some("B") { + Some("From B first") + } else if scope == Some("Empty") { + None + } else if local { + Some("From C") + } else { + None + }; + if let Some(expected) = expected { + result.unwrap(); + let caller = session.actions.list.iter().find(|action| action.name == "Caller").unwrap(); + let target = caller.steps[0].1[if conditional { "then" } else { "action" }].as_str().unwrap(); + let resolved = session.actions.list.iter().find(|action| action.name == target).unwrap(); + assert_eq!(resolved.steps[0].1["name"], expected); + let played = session.execute("actions.play", json!({"action":"Caller"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(session.active().unwrap().doc.layers.last().unwrap().name, expected); + } else { + let error = result.unwrap_err().to_string(); + assert!(error.contains("ambiguous cross-set"), "{error}"); + assert_eq!(session.actions, before, "refusal retains the original definitions"); + } + } + } + } +} diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs index 617339a5e61..4d164716fb6 100644 --- a/crates/engine/tests/it/atn_crystallize_cell_size.rs +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -1,4 +1,4 @@ -//! #7669: strict ATN export must not replace a native fractional Crystallize cell size. +//! #7669: native fallback preserves fractional cell sizes; Photoshop-only export omits them. use photocraft_engine::actions_cmds::Action; use photocraft_engine::{Session, atn_cmds}; use photocraft_psd::{atn, descriptor::Value as D}; @@ -27,10 +27,9 @@ fn pixels(s: &Session) -> Vec<[f32; 4]> { photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } -/// Fractions remain valid native edits; strict integer ATN conversion must refuse them. +/// Fractions retain their exact native settings and pixels; explicit Photoshop omission reports them. #[test] -fn fractional_cell_sizes_remain_native_and_are_refused_by_strict_export() { - let mut silently_exported = Vec::new(); +fn fractional_cell_sizes_use_native_events_without_pixel_loss() { for depth in [8, 16, 32] { for cell_size in [3.1, 5.4, 5.5, 5.6, 10.25, 299.9] { let params = json!({"cellSize": cell_size}); @@ -48,22 +47,20 @@ fn fractional_cell_sizes_remain_native_and_are_refused_by_strict_export() { rounded.execute(CRYSTALLIZE, json!({"cellSize": 5})).unwrap(); assert_ne!(pixels(&rounded), expected, "fixture must expose 5.4 vs 5: {depth}"); } - if let Ok((bytes, skipped)) = atn_cmds::export("Crystallize", &[&action], &[], false) { - let imported = atn_cmds::import(&bytes).unwrap(); - assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); - let exported = imported.actions[0].steps[0].1["cellSize"].clone(); - let mut replay = fixture(depth); - replay.actions.list = imported.actions; - replay.execute("actions.play", json!({"action": "Fractional Crystallize"})).unwrap(); - let changed = pixels(&replay) != expected; - if cell_size == 5.4 { - assert!(changed, "baseline must expose the reported pixel loss: {depth}"); - } - silently_exported.push(format!("depth {depth}: {cell_size} -> {exported}, pixels changed: {changed}")); - } + let (bytes, skipped) = atn_cmds::export("Crystallize", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let item = &atn::parse(&bytes).unwrap().actions[0].items[0]; + assert_eq!(item.event, atn::AtnEvent::name("photocraftCommand")); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions[0].steps, action.steps, "native fallback retains exact fractions"); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action":action.name})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "native fallback must not round pixels"); + crate::atn_export::fallback("Crystallize", &action); } } - assert!(silently_exported.is_empty(), "strict export silently accepted fractional cell sizes:\n{}", silently_exported.join("\n")); } /// Integer sizes, bounds and omitted defaults retain their binary descriptor and playback. @@ -104,8 +101,10 @@ fn integer_cell_sizes_and_defaults_preserve_playback() { /// Skipping is explicit and reports exactly which edits were lost, preserving supported order. #[test] fn skipping_fractional_cell_sizes_reports_action_and_step() { - let action = - Action::new("Mixed Crystallize", [5.0, 5.4, 10.0, 10.25].into_iter().map(|cell_size| (CRYSTALLIZE.into(), json!({"cellSize": cell_size}))).collect()); + let action = Action::new( + "Mixed Crystallize", + [json!(5), json!(5.4), json!(10), json!(10.25)].into_iter().map(|cell_size| (CRYSTALLIZE.into(), json!({"cellSize": cell_size}))).collect(), + ); let items = crate::atn_export::preserved("Crystallize", &action); assert_eq!(items.len(), 4); for index in [1, 3] { diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs index 38db75621e1..0320be9e928 100644 --- a/crates/engine/tests/it/atn_layer_style.rs +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -51,9 +51,10 @@ fn action(items: Vec) -> Action { loaded.actions.into_iter().next().unwrap() } +/// Inspect only the Photoshop mapping; unrepresentable native settings are tested as fallback. fn exported(id: &str, params: Value) -> Option { let a = Action::new("A", vec![(id.into(), params)]); - let bytes = export("S", &[&a], &[], false).ok()?.0; + let bytes = export("S", &[&a], &[], true).ok()?.0; atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() } @@ -273,6 +274,8 @@ fn layer_style_native_exports_are_strict_and_make_defaults_explicit() { assert!(exported(id, p).is_none()); } assert!(exported(CREATE, json!({"scale":100})).is_none()); + // Default export preserves native settings that the optional Photoshop mapping cannot carry. + crate::atn_export::fallback("S", &Action::new("A", vec![(SCALE.into(), json!({"scale":1.5}))])); } /// Malformed style references remain whole and cannot fall through to ordinary blank-layer creation. diff --git a/crates/engine/tests/it/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs index 0a88c275954..ae9a6b9ffdb 100644 --- a/crates/engine/tests/it/atn_ripple_amount.rs +++ b/crates/engine/tests/it/atn_ripple_amount.rs @@ -27,10 +27,9 @@ fn pixels(s: &Session) -> Vec<[f32; 4]> { photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } -/// Fractions are valid native edits, but the existing integer event cannot carry them. +/// Fractions retain native settings and pixels without approximating the integer Photoshop event. #[test] -fn fractional_amounts_remain_native_and_are_refused_by_strict_export() { - let mut silently_exported = Vec::new(); +fn fractional_amounts_use_native_events_without_pixel_loss() { for depth in [8, 16, 32] { for size in ["small", "medium", "large"] { for amount in [81.4, -81.4, 0.5, -0.5, 998.9, -998.9] { @@ -48,23 +47,21 @@ fn fractional_amounts_remain_native_and_are_refused_by_strict_export() { rounded.execute(RIPPLE, json!({"amount": 81, "size": size})).unwrap(); assert_ne!(pixels(&direct), pixels(&rounded), "fixture must expose rounding: {depth}, {size}"); } - if let Ok((bytes, skipped)) = atn_cmds::export("Ripple", &[&action], &[], false) { - let imported = atn_cmds::import(&bytes).unwrap(); - assert!(skipped.is_empty() && imported.unsupported.is_empty() && imported.warnings.is_empty()); - let exported = imported.actions[0].steps[0].1["amount"].clone(); - let mut replay = fixture(depth); - replay.actions.list = imported.actions; - replay.execute("actions.play", json!({"action": "Fractional Ripple"})).unwrap(); - let changed = pixels(&replay) != pixels(&direct); - if amount == 81.4 { - assert!(changed, "baseline must expose the reported pixel loss: {depth}, {size}"); - } - silently_exported.push(format!("depth {depth}, {size}: {amount} -> {exported}, pixels changed: {changed}")); - } + let (bytes, skipped) = atn_cmds::export("Ripple", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let item = &atn::parse(&bytes).unwrap().actions[0].items[0]; + assert_eq!(item.event, atn::AtnEvent::name("photocraftCommand")); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty() && imported.warnings.is_empty()); + assert_eq!(imported.actions[0].steps, action.steps, "native fallback retains exact fractions"); + let mut replay = fixture(depth); + replay.actions.list = imported.actions; + replay.execute("actions.play", json!({"action":action.name})).unwrap(); + assert_eq!(pixels(&replay), pixels(&direct), "native fallback must not round pixels"); + crate::atn_export::fallback("Ripple", &action); } } } - assert!(silently_exported.is_empty(), "strict export silently accepted fractional amounts:\n{}", silently_exported.join("\n")); } /// Whole amounts and omitted defaults still round-trip through binary ATN and actual pixels. @@ -109,8 +106,10 @@ fn integer_amounts_sizes_and_defaults_preserve_playback() { /// Opt-in skipping reports precisely the lost edits, keeping the supported steps in order. #[test] fn skipping_fractional_amounts_reports_action_and_step() { - let action = - Action::new("Mixed Ripple", [10.0, 81.4, -20.0, -0.5].into_iter().map(|amount| (RIPPLE.into(), json!({"amount": amount, "size": "small"}))).collect()); + let action = Action::new( + "Mixed Ripple", + [json!(10), json!(81.4), json!(-20), json!(-0.5)].into_iter().map(|amount| (RIPPLE.into(), json!({"amount": amount, "size": "small"}))).collect(), + ); let items = crate::atn_export::preserved("Ripple", &action); assert_eq!(items.len(), 4); for index in [1, 3] { diff --git a/crates/engine/tests/it/atn_shear.rs b/crates/engine/tests/it/atn_shear.rs index fb6e605d4da..c0267b95cf3 100644 --- a/crates/engine/tests/it/atn_shear.rs +++ b/crates/engine/tests/it/atn_shear.rs @@ -37,10 +37,10 @@ fn b64(bytes: &[u8]) -> String { fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported { import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap() } -/// Save one real native action and parse its public output, rather than calling a private converter. +/// Inspect the explicit Photoshop-only projection; default native fallback is checked separately. fn export_item(id: &str, params: &Value, set: &str) -> Option { let action = Action::new("A", vec![(id.into(), params.clone())]); - let bytes = export(set, &[&action], &[], false).ok()?.0; + let bytes = export(set, &[&action], &[], true).ok()?.0; atn::parse(&bytes).ok()?.actions.first()?.items.first().cloned() } @@ -134,6 +134,7 @@ fn shear_point_budget_preserves_recordings_and_refuses_oversized_native_exports( assert_eq!(export_item("filter.distort.shear", &native, "S").is_some(), count == LIMIT); assert_eq!(native, before, "export never edits the caller's curve"); if count > LIMIT { + crate::atn_export::fallback("S", &Action::new("A", vec![("filter.distort.shear".into(), native.clone())])); let metadata = json!({"points":[[0,0],[1,0]],"_atnShearPoints":grid}); assert!(export_item("filter.distort.shear", &metadata, "S").is_none()); } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index bde01cbca12..029dd85b549 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -11,6 +11,7 @@ mod actions_owned_preview; mod affinity; mod artboard_export_names; mod atn_action_colors; +mod atn_call_scopes; mod atn_crystallize_cell_size; mod atn_direct_adjustments; mod atn_layer_style; diff --git a/crates/ui-egui/src/dialogs/discard_ui.rs b/crates/ui-egui/src/dialogs/discard_ui.rs index 830f54a39c3..a5b860272ef 100644 --- a/crates/ui-egui/src/dialogs/discard_ui.rs +++ b/crates/ui-egui/src/dialogs/discard_ui.rs @@ -125,7 +125,10 @@ pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) { if !ctx.input(|i| i.viewport().close_requested()) { return; } - if crate::actions::guard_exit(app, ctx) { + if app.window_lost { + // The lost device cannot draw an Actions leave prompt. Release its capture before exit. + crate::actions::shutdown(app); + } else if crate::actions::guard_exit(app, ctx) { return; } if !app.allow_close && intercept(app, EXIT, &Value::Null) { diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests.rs index a75a1aeb99a..ffddbe98e6f 100644 --- a/crates/ui-egui/src/dialogs/discard_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/discard_ui/tests.rs @@ -629,6 +629,38 @@ fn window_close_goes_through_once_the_window_device_is_lost() { assert!(app.discard.is_none(), "no prompt opened"); } +/// Device loss cannot leave an owned step dialog trapping a blank window on Close. +#[test] +fn lost_window_close_releases_actions_ownership_without_a_prompt() { + use photocraft_engine::actions_cmds::Action; + for lost in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":8,"height":8})).unwrap(); + let mut action = Action::new("Owned", vec![("filter.blur.gaussianBlur".into(), json!({"radius":1}))]); + action.dialogs.insert(0); + app.session.actions.list.push(action); + app.run("actions.record", json!({"name":"Recorder"})).unwrap(); + let take = app.session.actions.take; + let value = crate::actions::play(&mut app, &json!({"action":"Owned"})).unwrap(); + assert!(value.get("paused").is_some(), "{value}"); + let fields = app.ui.dialogs.iter().find(|dialog| Some(dialog.id) == value["paused"]["dialog"].as_u64()).unwrap().fields.clone(); + let held = app.session.actions.held_playback; + assert!(held.is_some() && app.session.actions.recording.is_none()); + app.window_lost = lost; + let commands = window_close_commands(&mut app); + assert_eq!(commands.iter().any(|command| matches!(command, egui::ViewportCommand::CancelClose)), !lost); + assert_eq!(app.session.actions.take, take); + if lost { + assert!(app.session.actions.held_playback.is_none()); + assert!(crate::actions::resume(&mut app, &fields).is_err(), "the released token cannot resume"); + assert!(app.session.actions.recording.is_some(), "the suspended recorder was released"); + assert!(app.discard.is_none(), "no invisible document prompt"); + } else { + assert_eq!(app.session.actions.held_playback, held, "normal Close still waits for its owner"); + } + } +} + /// The title bar's close button and File › Exit run `file.exit` through `menus::invoke`, which /// parks it behind the prompt before any close request is made: once the window's device is /// lost they must close instead. diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index befd1e8f0dd..e422972c16c 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -16,7 +16,7 @@ use crate::icons; use crate::theme::Tokens; mod editor; -mod editor_glyphs; +pub(crate) mod editor_glyphs; mod editor_picker; mod modern; @@ -359,6 +359,27 @@ fn pause_frame(app: &mut PhotocraftApp, frame: &Frame, from: usize, dialog: u64) app.paused_playbacks.pause(frame.after(from), dialog, app.session.authorize, app.session.actions.playback_steps, doc) } +/// Open Spelling with the frame's document, gate and one-use continuation token. +fn pause_spelling_step(app: &mut PhotocraftApp, frame: &Frame, from: usize, params: &Value) -> Result<(), String> { + if let Some(open) = app.ui.type_panels.spell.as_ref() { + return Err(if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }.into()); + } + crate::type_panels_ui::open_spelling(app, params.get("allLayers").and_then(Value::as_bool).unwrap_or(true))?; + // Spelling owns its existing non-modal panel token, not a generic dialog id. + let doc = app.session.active().map(|d| d.doc.id); + let token = match app.paused_playbacks.pause(frame.after(from), 0, app.session.authorize, app.session.actions.playback_steps, doc) { + Ok(token) => token, + Err(error) => { + app.ui.type_panels.spell = None; + return Err(error); + } + }; + if let Some(dialog) = app.ui.type_panels.spell.as_mut() { + dialog.resume = Some(token); + } + Ok(()) +} + /// Execute one immutable frame with normal shell view/save, authorization and job ordering. fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_tail: bool) -> Result { frame.validate(&app.session)?; @@ -427,28 +448,10 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ // one keeps its place and ignored words. // A correction (`action` set) opens no dialog and runs below. if action.dialogs.contains(&i) && !app.automation_input && id == "edit.checkSpelling" && p.get("action").is_none() { - if let Some(open) = app.ui.type_panels.spell.as_ref() { - let error = if open.resume.is_some() { "Check Spelling is already open for another action" } else { "Check Spelling is already open" }; + if let Err(error) = pause_spelling_step(app, &frame, i + 1, p) { failed = Some(json!({"step": i, "id": id, "error": error})); break; } - if let Err(error) = crate::type_panels_ui::open_spelling(app, p.get("allLayers").and_then(Value::as_bool).unwrap_or(true)) { - failed = Some(json!({"step": i, "id": id, "error": error})); - break; - } - // Spelling owns its existing non-modal panel token, not a generic dialog id. - let doc = app.session.active().map(|d| d.doc.id); - let token = match app.paused_playbacks.pause(frame.after(i + 1), 0, app.session.authorize, app.session.actions.playback_steps, doc) { - Ok(token) => token, - Err(error) => { - app.ui.type_panels.spell = None; - failed = Some(json!({"step": i, "id": id, "error": error})); - break; - } - }; - if let Some(d) = app.ui.type_panels.spell.as_mut() { - d.resume = Some(token); - } paused = Some(json!({"step": i, "id": id, "dialog": crate::type_panels_ui::SPELLING})); break; } diff --git a/crates/ui-egui/src/panels/actions/editor.rs b/crates/ui-egui/src/panels/actions/editor.rs index 1434fcd667d..c572c1d625e 100644 --- a/crates/ui-egui/src/panels/actions/editor.rs +++ b/crates/ui-egui/src/panels/actions/editor.rs @@ -204,11 +204,11 @@ impl Page { let result = app.run(&format!("actions.editor.{verb}"), Value::Object(params)); match result { Ok(view) => { - if verb != "discard" { - if let Err(error) = self.accept(&view) { - self.error = Some(error.clone()); - return Err(error); - } + if verb != "discard" + && let Err(error) = self.accept(&view) + { + self.error = Some(error.clone()); + return Err(error); } if verb != "get" { self.error = None; @@ -467,7 +467,7 @@ impl Page { }; let mut shortcut_changed = false; ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("action-editor-fn").selected_text(label).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("action-editor-fn").selected_text(label).show_ui(ui, |ui| { for number in 0..=15 { let label = if number == 0 { tl!("None").to_string() } else { format!("F{number}") }; shortcut_changed |= ui.selectable_value(&mut chosen, number, label).changed(); @@ -483,10 +483,9 @@ impl Page { } if (name_edit.lost_focus() || description_edit.lost_focus() || shortcut_changed) && (self.name.dirty() || self.description.dirty() || self.shortcut.dirty()) + && let Ok(view) = self.stage(app) { - if let Ok(view) = self.stage(app) { - latest = Some(view); - } + latest = Some(view); } latest } @@ -809,7 +808,7 @@ fn field(ui: &mut egui::Ui, param: &filter_dialog::Param, values: &mut Map (Color32, Color32) { diff --git a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs index 654f247af5a..3341bc16ef8 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker_tests.rs @@ -107,7 +107,7 @@ fn six_by_five_icon_grid_returns_the_measured_identifier_for_each_button() { ("AdjustmentGeneral", "Adjustments"), ("HueAndSat", "Hue and saturation"), ("ActionsExposure", "Exposure"), - ("ColorLookUpTables", "Colour lookup tables"), + ("ColorLookUpTables", "Color lookup tables"), ("BlackAndWhite", "Black and white"), ("SmartRemove", "Smart removal"), ("ActionsDodge", "Dodge"), @@ -119,7 +119,7 @@ fn six_by_five_icon_grid_returns_the_measured_identifier_for_each_button() { ("ObjectSelectionLasso", "Object selection"), ("MagicWand", "Magic wand"), ("AIGenerate", "Generate"), - ("TextColor", "Text colour"), + ("TextColor", "Text color"), ("ActionsText", "Text"), ("ActionsDownload", "Download"), ("ActionsLayers", "Layers"), @@ -159,7 +159,7 @@ fn two_by_six_colour_grid_keeps_classic_blue_and_new_blue_distinct() { ("magenta", "Magenta"), ("purple", "Purple"), ("violet", "Lavender"), - ("gray", "Grey"), + ("gray", "Gray"), ]; let mut h = harness(UNKNOWN_ICON, UNKNOWN_COLOR); open(&mut h); diff --git a/crates/ui-egui/src/panels/actions/editor_tests.rs b/crates/ui-egui/src/panels/actions/editor_tests.rs index 07d78cd5bac..85e4f0f9be0 100644 --- a/crates/ui-egui/src/panels/actions/editor_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_tests.rs @@ -53,7 +53,7 @@ fn picker_choices_reach_the_owned_draft_and_only_save_publishes_them() { ); PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::Studio); harness.run_steps(3); - for label in ["Text colour", "Yellow"] { + for label in ["Text color", "Yellow"] { harness.get_by_role_and_label(Role::Button, "Choose icon and color").click(); harness.run_steps(3); harness.get_by_role_and_label(Role::Button, label).click(); diff --git a/crates/ui-egui/src/panels/actions/modern.rs b/crates/ui-egui/src/panels/actions/modern.rs index 8bc45ea322a..11cce9989df 100644 --- a/crates/ui-egui/src/panels/actions/modern.rs +++ b/crates/ui-egui/src/panels/actions/modern.rs @@ -1,5 +1,8 @@ //! Modern Actions list and runtime editor integration. Definitions remain engine commands. -use super::*; +use super::{ + Action, Frame, Gate, NEW_SET, PausedPlayback, PhotocraftApp, RESUME, Rect, Sense, Source, Tokens, Value, abandoned, actions_cmds, editor, editor_glyphs, + icons, json, load_dialog, paused_token, run_with_parents, set_label, suspended, vec2, waiting_dialog, +}; use crate::state::{ActionsLayout, ActionsListSize}; use std::sync::Arc; @@ -33,7 +36,7 @@ struct Progress { } enum Delete { - Action(Action), + Action(Box), Group(String, Vec, u64), } @@ -293,7 +296,7 @@ enum Operation { Duplicate(Value), DeleteAction(usize), DeleteGroup(String), - Assign(DragAction, String), + Assign(Box, String), NewGroup, } @@ -365,10 +368,8 @@ fn action_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, index: usize, action: if name.drag_started() { name.dnd_set_drag_payload(DragAction { index, source: action.clone() }); } - if hovered || app.ui.actions.selected == Some(index) { - if icons::button(ui, "settings", 24.0, false, tl!("Edit action")).clicked() { - operation = Some(Operation::Edit(index)); - } + if (hovered || app.ui.actions.selected == Some(index)) && icons::button(ui, "settings", 24.0, false, tl!("Edit action")).clicked() { + operation = Some(Operation::Edit(index)); } let menu = icons::button(ui, "ellipsis", 24.0, false, tl!("Actions")); egui::Popup::menu(&menu).show(|ui| { @@ -462,7 +463,7 @@ pub(super) fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }); if let Some(drag) = header.response.dnd_release_payload::() { - operation = Some(Operation::Assign((*drag).clone(), group.clone())); + operation = Some(Operation::Assign(Box::new((*drag).clone()), group.clone())); } header.response.context_menu(|ui| { operation = context(ui, None, Some(group), members.clone()).or(operation.take()); @@ -499,7 +500,7 @@ pub(super) fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }); if let Some(drag) = header.response.dnd_release_payload::() { - operation = Some(Operation::Assign((*drag).clone(), photocraft_engine::atn_cmds::UNGROUPED_SET.to_string())); + operation = Some(Operation::Assign(Box::new((*drag).clone()), photocraft_engine::atn_cmds::UNGROUPED_SET.to_string())); } } if list.is_empty() { @@ -819,7 +820,7 @@ fn ask_delete(app: &mut PhotocraftApp, index: Option, group: Option { let action = index.and_then(|index| app.session.actions.list.get(index)).cloned().ok_or("The selected action no longer exists")?; let message = crate::i18n::fmt(tl!("Delete the action \"{name}\"?"), &[("name", &action.name)]); - (Delete::Action(action), message) + (Delete::Action(Box::new(action)), message) } }; let fields = json!({"__label":"Delete action","message":message}); @@ -848,7 +849,7 @@ pub(crate) fn delete_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map { - let mut matches = app.session.actions.list.iter().enumerate().filter(|(_, current)| *current == &action).map(|(index, _)| index); + let mut matches = app.session.actions.list.iter().enumerate().filter(|(_, current)| *current == action.as_ref()).map(|(index, _)| index); let index = matches.next().ok_or("The selected action changed; select it again")?; if matches.next().is_some() { return Err("The selected action is ambiguous".into()); From e6dbd17d58316ef02fe18260100d42dc3f95b7b6 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 03:55:59 -0800 Subject: [PATCH 114/384] Cover both Actions layouts in the docking menu test --- .../ui-egui/src/panels/panel_docking/tests.rs | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/panel_docking/tests.rs b/crates/ui-egui/src/panels/panel_docking/tests.rs index fb2fb9bd79c..cf6a76e4e4a 100644 --- a/crates/ui-egui/src/panels/panel_docking/tests.rs +++ b/crates/ui-egui/src/panels/panel_docking/tests.rs @@ -744,23 +744,32 @@ fn floated_color_panel_menu_picks_the_mode() { assert_eq!(h.state().ui.color_panel.mode, crate::state::ColorPanelMode::RgbSliders); } +/// Docked Actions menus expose each layout's import/export commands. #[test] fn actions_panel_menu_includes_load_and_save_actions() { - let mut ready = false; - let mut menu = Harness::builder().build_ui_state( - move |ui, app: &mut PhotocraftApp| { - if !ready { - PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); - ready = true; - } - panel_menu(app, ui, "actions"); - }, - self::app(), - ); - menu.run_steps(2); - assert!(menu.query_all_by_label("Load Actions…").count() > 0); - assert!(menu.query_all_by_label("Save Actions…").count() > 0); - assert!(menu.query_all_by_label("New Action…").count() > 0); + use crate::state::ActionsLayout; + for (layout, load, save) in [(ActionsLayout::Classic, "Load Actions…", "Save Actions…"), (ActionsLayout::Modern, "Import Actions…", "Export Action…")] + { + let mut app = self::app(); + app.ui.actions_layout = layout; + let mut ready = false; + let mut menu = Harness::builder().build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "actions"); + }, + app, + ); + menu.run_steps(2); + assert!(menu.query_all_by_label(load).count() > 0); + assert!(menu.query_all_by_label(save).count() > 0); + if layout == ActionsLayout::Classic { + assert!(menu.query_all_by_label("New Action…").count() > 0); + } + } } #[test] From e1833c5a26ba979641acb42b7ea6a91bb91a29e6 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 04:01:57 -0800 Subject: [PATCH 115/384] Document Actions identity, persistence and ownership boundaries --- apps/photocraft-web/src/indexed_presets.rs | 9 +++++++ .../src/indexed_presets/migration.rs | 1 + apps/photocraft-web/src/preset_bridge.rs | 9 +++++++ crates/engine/src/cmd/actions_cmds/editor.rs | 1 + crates/engine/src/cmd/actions_cmds/library.rs | 12 ++++++++++ .../engine/src/cmd/actions_cmds/playback.rs | 2 ++ crates/engine/src/cmd/atn_cmds/convert.rs | 8 +++++++ crates/engine/src/cmd/atn_cmds/sidecar.rs | 18 ++++++++++++++ crates/ui-egui/src/panels/actions/editor.rs | 8 +++++++ crates/ui-egui/src/panels/actions/modern.rs | 24 +++++++++++++++++++ 10 files changed, 92 insertions(+) diff --git a/apps/photocraft-web/src/indexed_presets.rs b/apps/photocraft-web/src/indexed_presets.rs index b83c0ebca1e..fdf9f40a13f 100644 --- a/apps/photocraft-web/src/indexed_presets.rs +++ b/apps/photocraft-web/src/indexed_presets.rs @@ -14,6 +14,7 @@ const TIMEOUT_MS: f64 = 10_000.0; mod migration; +/// Retain a DOM exception's name and message so storage failures remain actionable to the caller. fn js(e: JsValue) -> String { if let Some(exception) = e.dyn_ref::() { format!("{}: {}", exception.name(), exception.message()) @@ -29,6 +30,7 @@ impl Listeners { fn new() -> Self { Self(Vec::new()) } + /// Own each installed callback until its scoped listener collection detaches it. fn add(&mut self, target: &EventTarget, name: &'static str, f: impl FnMut(Event) + 'static) -> Result<(), JsValue> { let cb = Closure::::new(f); target.add_event_listener_with_callback(name, cb.as_ref().unchecked_ref())?; @@ -37,6 +39,7 @@ impl Listeners { } } impl Drop for Listeners { + /// Detach every owned event listener before releasing its Rust callback, including on timeout. fn drop(&mut self) { for (target, name, cb) in &self.0 { let _ = target.remove_event_listener_with_callback(name, cb.as_ref().unchecked_ref()); @@ -49,6 +52,7 @@ struct Pending { _listeners: Listeners, } +/// Pair an IDB request promise with owned success/error listeners for the whole pending operation. fn request(request: &IdbRequest) -> Pending { let mut listeners = Listeners::new(); let promise = Promise::new(&mut |resolve, reject| { @@ -98,6 +102,7 @@ async fn before(promise: &Promise, deadline: f64) -> Result { result } +/// Open the versioned store within the shared deadline; close late or superseded connections. async fn open(deadline: f64) -> Result { let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?; let req = factory.open_with_u32(DATABASE, 2).map_err(js)?; @@ -155,6 +160,7 @@ async fn open(deadline: f64) -> Result { } } +/// Open and hydrate one startup bridge under a single deadline, closing the connection on read failure. pub async fn load() -> Result<(IdbDatabase, Bridge, Vec), String> { let deadline = js_sys::Date::now() + TIMEOUT_MS; let db = open(deadline).await?; @@ -168,6 +174,7 @@ pub async fn load() -> Result<(IdbDatabase, Bridge, Vec), String> { } } +/// Bound the record inventory before copying Blob bytes, preserving unreadable entries for recovery. async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec), String> { let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?; let req = tx.object_store(FILES).map_err(js)?.open_cursor().map_err(js)?; @@ -261,6 +268,8 @@ fn queue_batch(store: &web_sys::IdbObjectStore, batch: &Batch, committed: bool) Ok(()) } +/// Acknowledge only transaction completion; migration comparisons and writes share one bounded transaction. +/// A closed connection is replaced without rehydrating over newer unsaved bridge edits. pub async fn write(db: &std::rc::Rc>, batch: &Batch) -> Result<(), String> { if let Some(migration) = &batch.migration { if !matches!(batch.get(LEGACY_ACTIONS_FILE), Some(None)) diff --git a/apps/photocraft-web/src/indexed_presets/migration.rs b/apps/photocraft-web/src/indexed_presets/migration.rs index 19923579674..c891ada9c9c 100644 --- a/apps/photocraft-web/src/indexed_presets/migration.rs +++ b/apps/photocraft-web/src/indexed_presets/migration.rs @@ -26,6 +26,7 @@ impl Scope { } impl Drop for Scope { + /// Stop the migration decision and detach its live request callback so late Blob reads cannot write. fn drop(&mut self) { self.decision.borrow_mut().stopped = true; if let Some(request) = self.current.borrow_mut().take() { diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index 4f542e98f14..2df5e5b589c 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -63,6 +63,7 @@ struct State { #[derive(Clone, Default)] pub struct Bridge(Arc>); +/// Attach the memory-backed library while protecting shared tips if startup found unreadable records. pub fn session(backend: Bridge) -> (Session, Vec) { let mut session = Session::new(); let opened = preset_store::open(Box::new(backend.clone())); @@ -94,6 +95,7 @@ impl Bridge { self.0.lock().unwrap_or_else(|e| e.into_inner()) } + /// Validate per-record, total-byte and record-count bounds before changing the memory inventory. fn insert(s: &mut State, name: &str, bytes: &[u8]) -> Result<(), String> { if bytes.len() as u64 > record_limit(name)? { return Err(format!("{name}: preset record too large")); @@ -108,6 +110,7 @@ impl Bridge { Ok(()) } + /// Hydrate a validated startup record without scheduling it as an edit for the browser writer. pub fn load(&self, name: &str, bytes: Vec) -> Result<(), String> { Self::insert(&mut self.state(), name, &bytes) } @@ -164,11 +167,13 @@ impl Bridge { self.state().error = None; } + /// Show a rejected current edit before a transaction error; an older successful batch cannot erase it. pub fn error(&self) -> Option { let s = self.state(); s.rejected.values().next().cloned().or_else(|| s.error.clone()) } + /// Include in-flight writes, queued edits and rejected replacements in the unsaved indicator. pub fn unsaved(&self) -> bool { let s = self.state(); s.writing || !s.dirty.is_empty() || !s.rejected.is_empty() @@ -180,6 +185,7 @@ impl PresetBackend for Bridge { Ok(self.state().files.iter().map(|(k, b)| (k.clone(), b.len() as u64)).collect()) } + /// Read the live memory value under the caller's byte ceiling, without waiting for browser storage. fn read(&self, name: &str, max: u64) -> Result, String> { let s = self.state(); let bytes = s.files.get(name).ok_or_else(|| format!("{name}: not found"))?; @@ -189,6 +195,7 @@ impl PresetBackend for Bridge { Ok(bytes.to_vec()) } + /// Validate the exact legacy/ATN/backup tuple and capacity before staging one fixed migration batch. fn migrate_actions(&self, legacy: &[u8], atn: &[u8], published: bool) -> Result { let mut s = self.state(); let result = (|| -> Result { @@ -234,6 +241,7 @@ impl PresetBackend for Bridge { result } + /// Stage a bounded live replacement; only acceptance of that record clears its prior rejection. fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> { if matches!(name, LEGACY_ACTIONS_FILE | MIGRATED_ACTIONS_FILE) { return Err("legacy Actions records are migration-only".into()); @@ -259,6 +267,7 @@ impl PresetBackend for Bridge { Ok(()) } + /// Stage ordinary deletions while retaining migration-only records and tips needed by damaged groups. fn remove(&self, name: &str) -> Result<(), String> { if matches!(name, LEGACY_ACTIONS_FILE | MIGRATED_ACTIONS_FILE) { return Err("legacy Actions records are migration-only".into()); diff --git a/crates/engine/src/cmd/actions_cmds/editor.rs b/crates/engine/src/cmd/actions_cmds/editor.rs index 02d6a5de975..f3da70f352b 100644 --- a/crates/engine/src/cmd/actions_cmds/editor.rs +++ b/crates/engine/src/cmd/actions_cmds/editor.rs @@ -86,6 +86,7 @@ pub(super) fn preview_value(s: &Session, id: u64) -> Result { visible(s, draft) } +/// Require both this draft identity and its unchanged staged value before an owned preview resumes. pub(super) fn preview_matches(s: &Session, id: u64, snapshot: &Action) -> bool { s.actions.editor.as_ref().is_some_and(|draft| draft.id == id && &draft.value == snapshot) } diff --git a/crates/engine/src/cmd/actions_cmds/library.rs b/crates/engine/src/cmd/actions_cmds/library.rs index 9e1ba51ece7..70ad369e1fa 100644 --- a/crates/engine/src/cmd/actions_cmds/library.rs +++ b/crates/engine/src/cmd/actions_cmds/library.rs @@ -55,6 +55,7 @@ struct Archive { } impl PartialEq for Archive { + /// Archive equality denotes the same parsed input, not equal bytes from a separate load. fn eq(&self, other: &Self) -> bool { std::ptr::eq(self, other) } @@ -84,9 +85,11 @@ pub(crate) struct RowOrigin { } impl RowOrigin { + /// Transient physical row identity; repeated display names remain distinct. pub(crate) fn key(&self) -> (usize, usize) { (Arc::as_ptr(&self.archive) as usize, self.set) } + /// Resolve this physical row in its retained archive, rejecting an invalid ordinal. fn file(&self) -> Result<&AtnFile> { self.archive.parsed.sets.get(self.set).ok_or_else(|| "missing original set".into()) } @@ -100,21 +103,25 @@ pub(crate) struct LegacyOrigin { } impl LegacyOrigin { + /// Bind a bounded legacy input by byte count and digest before recording a migration receipt. pub(crate) fn new(bytes: &[u8]) -> Result { if bytes.len() > MAX_BYTES { return Err("legacy Actions library exceeds byte limit".into()); } Ok(Self { bytes: bytes.len(), digest: blake3::hash(bytes).to_hex().to_string() }) } + /// Require the exact original legacy bytes, including for same-size replacement inputs. pub(crate) fn matches(&self, bytes: &[u8]) -> bool { bytes.len() == self.bytes && blake3::hash(bytes).to_hex().as_str() == self.digest } + /// Encode the receipt version, bounded byte count and digest into the library sidecar. pub(crate) fn descriptor(&self) -> Result { Ok(Descriptor::new("null") .with("version", D::Integer(1)) .with("bytes", D::Integer(i32::try_from(self.bytes).map_err(|_| "legacy length overflow")?)) .with("digest", D::Text(UnicodeString::new(&self.digest)))) } + /// Validate the receipt schema, unique fields, byte ceiling and lowercase digest before use. pub(crate) fn from_descriptor(value: &Descriptor) -> Result { if !value.class_id.is("null") || !value.name.0.is_empty() || value.items.len() != 3 { return Err("invalid legacy Actions origin".into()); @@ -158,6 +165,7 @@ pub(crate) struct RowIndex<'a> { exact: std::collections::HashMap<(&'a str, (usize, usize)), RowOrigin>, } impl RowIndex<'_> { + /// Prefer the action's exact surviving physical row; otherwise use the first logical namesake. pub(crate) fn source(&self, action: &Action) -> Option { let name = atn_cmds::logical_set_name(action.set.as_deref()); action.library_origin.as_ref().and_then(|origin| self.exact.get(&(name, origin.row.key()))).or_else(|| self.first.get(name)).cloned() @@ -165,6 +173,7 @@ impl RowIndex<'_> { } impl Library { + /// Index surviving rows once, retaining both first-name resolution and exact provenance matches. pub(crate) fn row_index<'a>(&self, names: &'a [String]) -> RowIndex<'a> { let mut index = RowIndex { first: Default::default(), exact: Default::default() }; for (name, row) in names.iter().zip(&self.rows) { @@ -258,6 +267,7 @@ pub(crate) fn bind_import(action: &mut Action, original: &Action) { action.atn_metadata = original.atn_metadata.clone(); } +/// Attach one imported physical row to its destination slot without replacing an existing sidecar source. pub(crate) fn import_row(state: &mut ActionState, imported: &ActionState, index: usize, slot: usize) { state.library.rows.resize(state.sets.len(), None); if let Some(row) = state.library.rows.get_mut(slot) { @@ -400,6 +410,7 @@ fn action_record(action: &Action, set: &str, keys: &[(String, String)], unique_n Ok((output, OutputAction { origin: origin.cloned(), items: origins })) } +/// Copy a validated source span while enforcing the complete durable-library byte ceiling. fn append(out: &mut Vec, bytes: &[u8], range: Range) -> Result<()> { let value = bytes.get(range).ok_or("invalid Actions library byte span")?; if out.len().checked_add(value.len()).is_none_or(|length| length > MAX_BYTES) { @@ -409,6 +420,7 @@ fn append(out: &mut Vec, bytes: &[u8], range: Range) -> Result<()> { Ok(()) } +/// Reuse an original field only when unchanged; otherwise copy the canonical replacement span. fn field(out: &mut Vec, canonical: &[u8], new: Range, source: Option<(&Archive, Range)>, unchanged: bool) -> Result<()> { if let Some((source, old)) = source.filter(|_| unchanged) { append(out, &source.bytes, old) } else { append(out, canonical, new) } } diff --git a/crates/engine/src/cmd/actions_cmds/playback.rs b/crates/engine/src/cmd/actions_cmds/playback.rs index 8f476cc4eae..3cb81edc373 100644 --- a/crates/engine/src/cmd/actions_cmds/playback.rs +++ b/crates/engine/src/cmd/actions_cmds/playback.rs @@ -20,6 +20,7 @@ pub struct Owner { call: Option<(String, usize)>, } +/// Restore a Classic recorder only to one unchanged definition, allowing reorder but rejecting ambiguity. fn classic_index(s: &Session, action: &Action) -> Option { let mut matches = s.actions.list.iter().enumerate().filter(|(_, candidate)| *candidate == action).map(|(index, _)| index); let index = matches.next()?; @@ -41,6 +42,7 @@ pub fn admit(s: &Session, id: &str) -> Result<()> { Ok(()) } +/// Acquire one playback identity after jobs and capture flush complete, retaining the exact recording destination. fn suspend(s: &mut Session, preview: Option<(u64, Arc)>, call: Option<(String, usize)>, required: bool) -> Result> { if s.actions.held_playback.is_some() { return Err(bad("actions.play", "another action owns a suspended playback")); diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 1dc5afd3b24..348614c2a9d 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -43,6 +43,7 @@ pub struct ImportedSet { pub(crate) metadata: Option>, } +/// Retain an unsupported event's ID kind and descriptor for later export without executing it. fn photoshop_step(item: &AtnItem, label: String) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); let event_kind = match &item.event { @@ -79,6 +80,7 @@ pub(super) fn clip_name(s: &str) -> String { s.chars().take(MAX_NAME_CHARS).collect() } +/// Project events under archive-wide step limits, retaining flags and optional original-event cohorts. fn convert_action( file: &AtnFile, a: &AtnAction, @@ -166,6 +168,7 @@ pub(crate) fn library_action(file: &AtnFile, action: &AtnAction, set: &str) -> s const NATIVE_EVENT: &str = "photocraftCommand"; +/// Read one bounded, uniquely named UTF16 TEXT field from a native event descriptor. fn native_text(descriptor: &Descriptor, key: &str) -> std::result::Result { let mut values = descriptor.items.iter().filter(|(id, _)| id.is(key)); let Some((_, D::Text(text))) = values.next() else { return Err(format!("native action step needs TEXT {key}")) }; @@ -176,6 +179,7 @@ fn native_text(descriptor: &Descriptor, key: &str) -> std::result::Result std::result::Result, String> { if item.event != AtnEvent::name(NATIVE_EVENT) { return Ok(None); @@ -194,6 +198,7 @@ fn read_native(item: &AtnItem) -> std::result::Result, S Ok(Some((id, params))) } +/// Encode an unmapped native command and its complete JSON without losing unsupported options. pub(crate) fn native_item(id: &str, params: &Value) -> std::result::Result { let params = serde_json::to_string(params).map_err(|error| error.to_string())?; if id.is_empty() || id.len() > crate::preset_store::MAX_ACTIONS_BYTES as usize || params.len() > crate::preset_store::MAX_ACTIONS_BYTES as usize { @@ -296,6 +301,7 @@ pub struct Skipped { pub id: String, } +/// Project one kept or mapped Photoshop event, supplying the caller set for unscoped action references. pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option { if id == PHOTOSHOP_STEP { let event = jstr(params, "event")?; @@ -393,6 +399,7 @@ pub fn export( export_groups(&[(set, selection)], function_keys, skip_unsupported) } +/// Validate selected archive counts, export each group and append its positional metadata carrier. pub(super) fn export_groups( groups: &[(&str, super::sidecar::Selection<'_>)], function_keys: &[(String, String)], @@ -430,6 +437,7 @@ pub(super) fn export_groups( Ok((bytes, skipped)) } +/// Preserve step order and flags; report explicit omissions or retain unmapped commands as native events. fn export_set( set: &str, actions: &[&Action], diff --git a/crates/engine/src/cmd/atn_cmds/sidecar.rs b/crates/engine/src/cmd/atn_cmds/sidecar.rs index feefec560ed..3fb2744e4d5 100644 --- a/crates/engine/src/cmd/atn_cmds/sidecar.rs +++ b/crates/engine/src/cmd/atn_cmds/sidecar.rs @@ -14,6 +14,7 @@ const EMPTY_LIBRARY: &str = "photocraftEmptyLibraryV1"; const MAX_BYTES: usize = crate::preset_import_cmds::MAX_FILE_BYTES as usize; type Result = std::result::Result; +/// Enforce the external action-file byte ceiling before extending an encoded sidecar. fn extend(out: &mut Vec, bytes: &[u8]) -> Result<()> { if out.len().checked_add(bytes.len()).is_none_or(|length| length > MAX_BYTES) { return Err("sidecar exceeds the action-file byte limit".into()); @@ -42,6 +43,7 @@ fn key_len(key: &Id) -> usize { } } +/// Serialize only a descriptor value so field patches retain their original key bytes. fn value_bytes(value: D) -> Result> { let header = Descriptor::new("null").to_bytes().len(); let bytes = Descriptor::new("null").with("value", value).to_bytes(); @@ -49,6 +51,7 @@ fn value_bytes(value: D) -> Result> { } impl RawDescriptor { + /// Validate and retain the bounded original descriptor bytes before accepting span-based edits. fn new(bytes: &[u8]) -> Result { if bytes.len() > MAX_BYTES { return Err("sidecar exceeds the action-file byte limit".into()); @@ -65,6 +68,7 @@ impl RawDescriptor { Descriptor::from_bytes(self.bytes()).map_err(|error| error.to_string()) } + /// Locate original field spans and reject inconsistent lengths instead of reconstructing opaque bytes. fn fields(&self) -> Result<(usize, Vec)> { let descriptor = self.parsed()?; let header = Descriptor { name: descriptor.name.clone(), class_id: descriptor.class_id.clone(), items: Vec::new() }.to_bytes().len(); @@ -84,6 +88,7 @@ impl RawDescriptor { Ok((header, fields)) } + /// Resolve a unique object list into shared source spans, rejecting oversized or malformed entries. fn list(&self, name: &str) -> Result> { let (_, fields) = self.fields()?; let mut matching = fields.into_iter().filter(|field| field.key.is(name)); @@ -171,6 +176,7 @@ impl RawDescriptor { } } +/// Encode bounded object-list entries without reserializing their retained descriptors. fn objects(entries: &[Vec]) -> Result> { if entries.len() > atn::MAX_TOTAL_ACTIONS { return Err("too many sidecar objects".into()); @@ -233,10 +239,12 @@ pub(crate) fn explicit_icon(action: &mut Action) { } } +/// Use an empty-row carrier only when both its current slot and displayed row still match. pub(super) fn empty_source(state: &ActionState, slot: usize) -> Option> { state.atn_set_rows.iter().find(|entry| entry.slot == slot && state.sets.get(slot) == Some(&entry.row)).map(|entry| entry.source.clone()) } +/// Append one physical row and its optional carrier; provenance starts unset until explicitly bound. pub(crate) fn install_row(state: &mut ActionState, name: &str, source: Option>) { let slot = state.sets.len(); state.library.rows.resize(slot, None); @@ -292,6 +300,7 @@ pub(crate) fn retire_sets(state: &mut ActionState, names: &std::collections::Has } } +/// Inherit group metadata only when all action and empty-row carriers identify one source set. fn group_source(state: &ActionState, name: &str) -> Option> { let actions: Vec<&Action> = state.list.iter().filter(|action| super::logical_set_name(action.set.as_deref()) == name).collect(); let mut source = match source_for(&actions) { @@ -312,6 +321,7 @@ fn group_source(state: &ActionState, name: &str) -> Option> { source } +/// Compare the source allocation and physical set ordinal, rather than metadata or display-name equality. fn same_set(left: &Arc, right: &Arc) -> bool { Arc::ptr_eq(&left.source, &right.source) && left.ordinal == right.ordinal } @@ -328,12 +338,14 @@ pub(crate) fn assign_set_metadata(state: &mut ActionState, action: usize, target } } +/// Select one action with its set metadata and exact raw-row provenance when still available. pub(crate) fn selection<'a>(state: &ActionState, action: &'a Action) -> Selection<'a> { let source = action.atn_metadata.as_ref().and_then(|metadata| metadata.set.clone()).or_else(|| group_source(state, super::logical_set_name(action.set.as_deref()))); Selection { actions: vec![action], source, raw: state.library.row_source(&state.sets, action) } } +/// Build the portable logical-set selection, preserving its opaque metadata and enforcing archive limits. pub(crate) fn groups<'a>(state: &'a ActionState, only: Option<&str>) -> Result)>> { groups_ordered(state, only, false) } @@ -481,6 +493,7 @@ pub(crate) struct Decoded { pub(crate) source: Option>, } +/// Reject missing or repeated schema fields instead of choosing an arbitrary occurrence. fn unique<'a>(descriptor: &'a Descriptor, key: &str) -> Result<&'a D> { let mut fields = descriptor.items.iter().filter(|(id, _)| id.is(key)); let value = fields.next().map(|(_, value)| value).ok_or_else(|| format!("missing sidecar {key}"))?; @@ -494,6 +507,7 @@ pub(super) fn has_payload(item: &AtnItem) -> bool { (item.event.is("stop") || item.event.is("Stop")) && item.descriptor.as_ref().is_some_and(|descriptor| descriptor.get(MODERN).is_some()) } +/// Recognize the complete measured wrapper shape before treating its versioned bytes as a sidecar. fn payload(file: &AtnFile) -> Option<&[u8]> { if file.name.to_string_lossy() != "Action sidecar" || file.expanded { return None; @@ -525,6 +539,7 @@ pub(crate) fn is_wrapper(file: &AtnFile) -> bool { payload(file).is_some() } +/// Read at most one typed migration receipt from the retained outer descriptor. pub(crate) fn legacy_origin(source: Option<&Source>) -> Result> { let Some(source) = source else { return Ok(None) }; let root = source.root.parsed()?; @@ -650,12 +665,14 @@ fn decode_inner(files: &[AtnFile], library: bool, raw_outer: Option<&[u8]>) -> R Ok(result) } +/// Install the decoded visible metadata while retaining its opaque source carrier for later edits. pub(crate) fn apply(action: &mut Action, metadata: ActionMetadata) { action.description = metadata.description.clone(); action.icon = metadata.icon.clone(); action.atn_metadata = Some(metadata); } +/// Patch only changed description/icon fields; unchanged and unknown metadata retains its original bytes. fn entry(action: &Action) -> Result> { // Bound strings before UnicodeString allocates their UTF16 representation. let units = action.description.encode_utf16().count().checked_add(action.icon.encode_utf16().count()).ok_or("metadata string length overflow")?; @@ -692,6 +709,7 @@ pub(crate) struct Selection<'a> { pub(crate) raw: Option, } +/// Reject selections with conflicting physical source sets, even when their display names match. pub(super) fn source_for(actions: &[&Action]) -> Result>> { let mut source: Option> = None; for candidate in actions.iter().filter_map(|action| action.atn_metadata.as_ref()).filter_map(|metadata| metadata.set.as_ref()) { diff --git a/crates/ui-egui/src/panels/actions/editor.rs b/crates/ui-egui/src/panels/actions/editor.rs index c572c1d625e..25388ef1419 100644 --- a/crates/ui-egui/src/panels/actions/editor.rs +++ b/crates/ui-egui/src/panels/actions/editor.rs @@ -176,6 +176,7 @@ impl Page { self.prompt.is_some() || self.editing.is_some() } + /// Validate the response's draft identity and shape before refreshing buffers or step selection. fn accept(&mut self, view: &Value) -> Result<(), String> { if view.get("draft").and_then(Value::as_u64) != Some(self.draft) || view.get("action").and_then(|action| action.get("name")).and_then(Value::as_str).is_none() @@ -195,6 +196,7 @@ impl Page { Ok(()) } + /// Bind every backend edit to this draft and retain its error without replacing the page. fn call(&mut self, app: &mut PhotocraftApp, verb: &str, params: Value) -> Result { if self.closed { return Err(tl!("This editor page is closed").into()); @@ -222,6 +224,7 @@ impl Page { } } + /// Refresh this draft from backend inspection without executing a new editor command. fn get(&mut self, app: &mut PhotocraftApp) -> Result { match photocraft_engine::actions_cmds::editor::inspect(&app.session, self.draft).map_err(|error| error.to_string()) { Ok(view) => { @@ -235,10 +238,12 @@ impl Page { } } + /// Include unsubmitted text buffers as well as backend edits when deciding whether leaving needs confirmation. fn dirty(&self, view: &Value) -> bool { view.get("dirty").and_then(Value::as_bool) == Some(true) || self.name.dirty() || self.description.dirty() || self.shortcut.dirty() } + /// Submit only changed metadata fields, leaving unknown and untouched Action values with the backend. fn stage(&mut self, app: &mut PhotocraftApp) -> Result { let mut params = Map::new(); for (key, buffer) in [("name", &self.name), ("description", &self.description), ("shortcut", &self.shortcut)] { @@ -253,6 +258,7 @@ impl Page { app.session.prefs().dialogs.get(CONFIRM_PREF).and_then(|value| value.get("skip")).and_then(Value::as_bool) == Some(true) } + /// Offer Save only for a dirty draft and apply the stored skip preference through the same answer path. fn ask_save(&mut self, app: &mut PhotocraftApp, activity: Activity) -> Result, String> { let view = self.get(app)?; if self.dirty(&view) { @@ -287,6 +293,7 @@ impl Page { } } + /// Resolve Save/Discard/Cancel without closing the page on backend refusal or an unfinished recorded step. fn answer(&mut self, app: &mut PhotocraftApp, answer: Answer, activity: Activity) -> Result, String> { let intent = self.prompt.ok_or("There is no Save Action prompt")?; if matches!(answer, Answer::Cancel) { @@ -330,6 +337,7 @@ impl Page { Ok(Some(Event::Closed { saved: matches!(answer, Answer::Save), source: self.source })) } + /// Stage local metadata before requesting preview of this draft from the selected original step index. fn preview(&mut self, app: &mut PhotocraftApp, from: usize) -> Result { let view = self.stage(app)?; if from >= steps(&view).len() { diff --git a/crates/ui-egui/src/panels/actions/modern.rs b/crates/ui-egui/src/panels/actions/modern.rs index 11cce9989df..767cb2b87eb 100644 --- a/crates/ui-egui/src/panels/actions/modern.rs +++ b/crates/ui-egui/src/panels/actions/modern.rs @@ -40,6 +40,7 @@ enum Delete { Group(String, Vec, u64), } +/// Include backend drafts and suspended playback so layout/navigation guards cannot bypass hidden owners. pub(crate) fn editor_busy(app: &PhotocraftApp) -> bool { app.actions_runtime.page.is_some() || app.session.actions.editor.is_some() || app.actions_runtime.task.is_some() || suspended(app) } @@ -52,6 +53,7 @@ pub(crate) fn layout_checked(app: &PhotocraftApp, id: &str) -> Option { } } +/// Route layout changes through the current editor's leave flow before replacing its presentation. pub(crate) fn layout_command(app: &mut PhotocraftApp, id: &str) -> Option> { let layout = match id { "actions.layout.modern" => ActionsLayout::Modern, @@ -65,6 +67,7 @@ pub(crate) fn layout_command(app: &mut PhotocraftApp, id: &str) -> Option Value { let page = app.actions_runtime.page.as_ref(); json!({ @@ -113,10 +118,12 @@ fn activity(app: &PhotocraftApp) -> editor::Activity { editor::Activity { playback_busy: app.actions_runtime.task.is_some() || suspended(app), finishing_step: app.session.has_recording_job() } } +/// Prefer the current command gate, inheriting the automation gate only for automation input. fn effective_gate(app: &PhotocraftApp) -> Option { app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) } +/// Attach a visible page to an existing backend draft without allocating or replacing that draft. fn attach(app: &mut PhotocraftApp) -> Result<(), String> { if app.actions_runtime.page.is_none() && let Some(draft) = app.session.actions.editor.as_ref() @@ -136,6 +143,7 @@ pub(crate) fn discarded(app: &mut PhotocraftApp, draft: u64) { } } +/// Open one owned editor page only when no draft or playback already occupies the Actions flow. fn open(app: &mut PhotocraftApp, params: Value) -> Result { if editor_busy(app) { return Err(tl!("Close the open Actions editor first").into()); @@ -146,6 +154,7 @@ fn open(app: &mut PhotocraftApp, params: Value) -> Result { Ok(json!({"draft":id})) } +/// Apply page outcomes to selection, pending navigation or preview without publishing definitions directly. fn event(app: &mut PhotocraftApp, event: editor::Event) -> Result<(), String> { match event { editor::Event::Saved { source } | editor::Event::Closed { saved: true, source } => { @@ -198,6 +207,7 @@ pub(super) fn leave_for(app: &mut PhotocraftApp, id: &str, params: Value) -> Res Ok(json!({"pending":app.actions_runtime.pending.is_some()})) } +/// Resume the parked destination with its original authorization gate; defer actual Exit to the close flow. fn finish_navigation(app: &mut PhotocraftApp) -> Result<(), String> { let Some((id, params, gate)) = app.actions_runtime.pending.take() else { return Ok(()) }; let saved = app.session.authorize; @@ -267,6 +277,7 @@ pub(super) fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Export the selected action through the Photoshop-only projection and sanitize its suggested filename. fn export(app: &mut PhotocraftApp) -> Result { let index = app.ui.actions.selected.ok_or("Select an action to export")?; let action = app.session.actions.list.get(index).ok_or("The selected action no longer exists")?; @@ -543,12 +554,14 @@ fn new_group(app: &mut PhotocraftApp) -> Result { Ok(json!({"dialog":id})) } +/// Queue an immutable draft snapshot with the backend owner that suspends capture for its entire preview. fn start_preview(app: &mut PhotocraftApp, draft: u64, from: usize) -> Result { let (action, owner) = actions_cmds::playback::preview(&mut app.session, draft, from).map_err(|error| error.to_string())?; let frame = Frame { action, from, source: Source::Draft(draft), take: app.session.actions.take }; queue(app, frame, owner, Vec::new()) } +/// Snapshot remaining group actions and acquire a frame-spanning owner for the first resolved definition. fn start_library(app: &mut PhotocraftApp, indices: &[usize]) -> Result { let Some((&index, rest)) = indices.split_first() else { return Ok(json!({"ran":0})) }; let then = rest @@ -560,6 +573,7 @@ fn start_library(app: &mut PhotocraftApp, indices: &[usize]) -> Result) -> Result { if app.actions_runtime.task.is_some() { owner.finish(&mut app.session); @@ -574,6 +588,7 @@ fn queue(app: &mut PhotocraftApp, frame: Frame, owner: actions_cmds::playback::O Ok(json!({"playing":true})) } +/// Transfer a confirmed dialog continuation back into the tick task without replacing its owner or budgets. pub(super) fn resume_tick(app: &mut PhotocraftApp, paused: PausedPlayback) -> Result<(), String> { if let Some(owner) = &paused.record && let Err(error) = owner.yielding(&mut app.session, true) @@ -600,6 +615,8 @@ pub(crate) fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { tick_with_quantum(app, ctx, 8); } +/// Advance bounded work under the captured gate, transferring ownership across nested frames and dialogs. +/// Completion, cancellation and failure must release the same capture owner. fn tick_with_quantum(app: &mut PhotocraftApp, ctx: &egui::Context, quantum: usize) { if app.actions_runtime.page.is_none() && app.actions_runtime.pending.as_ref().is_some_and(|(id, _, _)| id == "__actionsExit") { app.actions_runtime.pending = None; @@ -718,6 +735,7 @@ fn tick_with_quantum(app: &mut PhotocraftApp, ctx: &egui::Context, quantum: usiz } } +/// Release incremental task and dialog owners, closing only their own Spelling panel or step dialogs. pub(super) fn cancel(app: &mut PhotocraftApp) { if let Some(mut task) = app.actions_runtime.task.take() && let Some(owner) = task.owner.take() @@ -745,6 +763,7 @@ pub(super) fn cancel(app: &mut PhotocraftApp) { app.actions_runtime.parents.clear(); } +/// Release all remaining playback captures during application exit, including non-incremental continuations. pub(crate) fn shutdown(app: &mut PhotocraftApp) { cancel(app); for (_, paused) in std::mem::take(&mut app.paused_playbacks.paused) { @@ -758,6 +777,7 @@ pub(crate) fn guard_navigation(app: &mut PhotocraftApp, id: &str, params: Value) editor_busy(app).then(|| leave_for(app, id, params)) } +/// Hold normal window Close behind the current editor leave question, preserving any pending destination. pub(crate) fn guard_exit(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { if !editor_busy(app) { return false; @@ -771,6 +791,7 @@ pub(crate) fn guard_exit(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { true } +/// Keep owned questions visible off-tab and resume parked navigation only after a successful page close. pub(crate) fn modals(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.actions_runtime.page_drawn == Some(app.frame) { return; @@ -794,6 +815,7 @@ pub(crate) fn owns_delete(fields: &serde_json::Map) -> bool { fields.contains_key(DELETE) } +/// Capture the selected definition or group revision and authorization gate before opening its delete question. fn ask_delete(app: &mut PhotocraftApp, index: Option, group: Option) -> Result { if app.actions_runtime.delete.is_some() { return Err("Answer the open delete question first".into()); @@ -836,12 +858,14 @@ pub(crate) fn delete_body(ui: &mut egui::Ui, fields: &mut serde_json::Map) { if app.actions_runtime.delete.as_ref().is_some_and(|(id, _, _)| fields.get(DELETE).and_then(Value::as_u64) == Some(*id)) { app.actions_runtime.delete = None; } } +/// Reject a stale or ambiguous captured target before deleting it under the question's original gate. pub(crate) fn delete_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result { let (id, target, gate) = app.actions_runtime.delete.take().ok_or("The delete question is no longer current")?; if fields.get(DELETE).and_then(Value::as_u64) != Some(id) { From 5a29baa05b50f40884a8f73a38cebac895b2288d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 04:53:38 -0800 Subject: [PATCH 116/384] Preserve explicit scopes in the durable ATN import fixture --- crates/engine/tests/it/atn_call_scopes.rs | 37 ++++++++++++++++++----- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 60cc11da01f..3876cb92bd9 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -1,20 +1,34 @@ //! Import resolves explicit set names before normalizing duplicate action names. -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, +}; use photocraft_psd::{ atn::{self, AtnFile}, descriptor::UnicodeString, }; use serde_json::json; -/// Each call to the public exporter supplies one physical row, including repeated names. -fn row(name: &str, actions: Vec) -> AtnFile { +/// The durable writer preserves explicit set scopes through its exact native fallback. +/// Portable Photoshop projection may omit those scopes; it is not this loader fixture's input. +fn row(name: &str, mut actions: Vec) -> AtnFile { if actions.is_empty() { return AtnFile { name: UnicodeString::new_nul(name), ..Default::default() }; } - let refs: Vec<_> = actions.iter().collect(); - let (bytes, skipped) = atn_cmds::export(name, &refs, &[], false).unwrap(); - assert!(skipped.is_empty()); - atn::parse(&bytes).unwrap() + let store = MemBackend::default(); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + for action in &mut actions { + action.set = Some(name.to_string()); + } + session.actions.list = actions; + session.actions.rev += 1; + session.sync_preset_store(); + let bytes = store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(); + let mut rows = atn::parse_sets(&bytes).unwrap(); + assert_eq!(rows.len(), 1, "metadata-free fixture has exactly its real set row"); + rows.remove(0) } fn finish(marker: &str) -> Action { @@ -43,6 +57,13 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( row("C", caller), row("Empty", Vec::new()), ]; + if let Some(scope) = scope { + let caller = files[3].actions.iter().find(|action| action.name.to_string_lossy() == "Caller").unwrap(); + assert_eq!(caller.items[0].event, atn::AtnEvent::name("photocraftCommand"), "fixture must retain the recorded set"); + let bytes = atn::write(&files[3]).unwrap(); + let imported = photocraft_engine::atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0].1["set"], scope); + } let bytes = atn::write_sets(&files).unwrap(); let mut session = Session::new(); session.execute("file.new", json!({"width":8,"height":8})).unwrap(); @@ -58,7 +79,7 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( None }; if let Some(expected) = expected { - result.unwrap(); + result.unwrap_or_else(|error| panic!("conditional={conditional}, local={local}, scope={scope:?}: {error}")); let caller = session.actions.list.iter().find(|action| action.name == "Caller").unwrap(); let target = caller.steps[0].1[if conditional { "then" } else { "action" }].as_str().unwrap(); let resolved = session.actions.list.iter().find(|action| action.name == target).unwrap(); From a600211f781e3e5363691b45757f0808d9846ccd Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 05:14:19 -0800 Subject: [PATCH 117/384] Import Actions layout test dependencies explicitly --- crates/ui-egui/src/panels/actions/new_layout_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/actions/new_layout_tests.rs b/crates/ui-egui/src/panels/actions/new_layout_tests.rs index 9c60c486756..10feff63c9a 100644 --- a/crates/ui-egui/src/panels/actions/new_layout_tests.rs +++ b/crates/ui-egui/src/panels/actions/new_layout_tests.rs @@ -1,5 +1,6 @@ //! Modern presentation and its actual runtime ownership/navigation seams. use super::*; +use crate::{FileDialogRequest, actions::new_set_dialog}; use egui_kittest::{Harness, kittest::Queryable}; fn app() -> PhotocraftApp { From c9f8b8f2988537528ce033352a14d5b2082fda0a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 05:18:33 -0800 Subject: [PATCH 118/384] Regenerate scorecard for the Actions panel preference --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index a94cca1468e..713d5a3dc8d 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 186 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 187 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 640 of 640 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -203,7 +203,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 186** (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: 10 of 187** (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`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From 8482537348585545bcb8e8cbfac41e707a9a4dc5 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 05:23:30 -0800 Subject: [PATCH 119/384] Import the Actions test continuation explicitly --- crates/ui-egui/src/panels/actions/new_layout_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/actions/new_layout_tests.rs b/crates/ui-egui/src/panels/actions/new_layout_tests.rs index 10feff63c9a..a914b6786f4 100644 --- a/crates/ui-egui/src/panels/actions/new_layout_tests.rs +++ b/crates/ui-egui/src/panels/actions/new_layout_tests.rs @@ -1,6 +1,9 @@ //! Modern presentation and its actual runtime ownership/navigation seams. use super::*; -use crate::{FileDialogRequest, actions::new_set_dialog}; +use crate::{ + FileDialogRequest, + actions::{new_set_dialog, resume}, +}; use egui_kittest::{Harness, kittest::Queryable}; fn app() -> PhotocraftApp { From 176a26833b84b6078cee64d0a7f6ef6fddcfe4cf Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 06:41:59 -0800 Subject: [PATCH 120/384] Document the modern Actions panel and editor with native captures --- ATTRIBUTION.md | 1 + docs/images/actions-editor/README.md | 12 ++++++++++++ docs/images/actions-editor/classic-before.png | Bin 0 -> 93842 bytes docs/images/actions-editor/editor.png | Bin 0 -> 106585 bytes .../actions-editor/icon-color-picker.png | Bin 0 -> 121678 bytes docs/images/actions-editor/modern-after.png | Bin 0 -> 93512 bytes 6 files changed, 13 insertions(+) create mode 100644 docs/images/actions-editor/README.md create mode 100644 docs/images/actions-editor/classic-before.png create mode 100644 docs/images/actions-editor/editor.png create mode 100644 docs/images/actions-editor/icon-color-picker.png create mode 100644 docs/images/actions-editor/modern-after.png diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9a3c30d4bf2..73aad82f2c1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -98,6 +98,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/actions-editor/*.png` | Classic and modern Actions panels, action editor, and icon/color picker (synthetic document) | PhotoCraft contributors | Original synthetic document and actions rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0 | | `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 | | `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 | | `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 | diff --git a/docs/images/actions-editor/README.md b/docs/images/actions-editor/README.md new file mode 100644 index 00000000000..9cdc751463c --- /dev/null +++ b/docs/images/actions-editor/README.md @@ -0,0 +1,12 @@ +# Actions editor screenshots + +Original PhotoCraft UI captures rendered with the offscreen `snapshot` example at +1440 × 900, scale 1, with a synthetic gray document and two recorded actions. + +- `classic-before.png`: the Classic Actions panel. +- `modern-after.png`: the modern Actions list with a group and action metadata. +- `editor.png`: the action editor and its recording, playback, and step controls. +- `icon-color-picker.png`: the action icon and color choices. + +The captures use the dark appearance and CPU document canvas. The UI and original +synthetic content are licensed under MIT OR Apache-2.0, like PhotoCraft. diff --git a/docs/images/actions-editor/classic-before.png b/docs/images/actions-editor/classic-before.png new file mode 100644 index 0000000000000000000000000000000000000000..e34e57b8060825e10ab698511f0be1d3e510152a GIT binary patch literal 93842 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

@Tc%U$dYi!dYi5*k5oo9WwjXg+WUe~N79ei#rH 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 BP8$FK literal 0 HcmV?d00001 diff --git a/docs/images/actions-editor/editor.png b/docs/images/actions-editor/editor.png new file mode 100644 index 0000000000000000000000000000000000000000..a07b32ce6ad15acd0640f912fc2cc8109b55f8c4 GIT binary patch literal 106585 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⩔c#G=jJU>`+#@ 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 ZblaA;mTj`@Gy~o~SiX8$-FsWU{2P@3wod>6 literal 0 HcmV?d00001 From fdce54967e21958cc15073f97a1cbb01f2290dc3 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:11:55 -0800 Subject: [PATCH 121/384] Preserve imported call scope and safe ATN migration boundaries --- apps/photocraft-cli/src/actions.rs | 16 ++- apps/photocraft-cli/tests/it/cli.rs | 67 +++++++++ assets/icons/action-editor/README.md | 6 +- crates/engine/src/cmd/atn_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds/convert.rs | 100 +++++++++++++- crates/engine/src/cmd/atn_cmds/files.rs | 76 +---------- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 11 +- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 21 ++- crates/engine/tests/it/atn_call_scopes.rs | 30 +++- .../engine/tests/it/atn_native_admission.rs | 46 +++++++ crates/engine/tests/it/main.rs | 1 + crates/format/src/noreplace.rs | 128 ++++++++++++++++-- .../src/panels/actions/editor_glyph_tests.rs | 2 +- .../src/panels/actions/editor_glyphs.rs | 2 +- .../src/panels/actions/editor_picker.rs | 4 +- docs/atn.md | 9 ++ 16 files changed, 416 insertions(+), 105 deletions(-) create mode 100644 crates/engine/tests/it/atn_native_admission.rs diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index a17ca7586df..6d9e7b7141f 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -109,25 +109,26 @@ pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec< let (steps, set) = load_actions(&text, name)?; return Ok((steps, set, Vec::new())); } - let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; - let action = match (name, imp.actions.as_slice()) { + let mut imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; + let selected = match (name, imp.actions.as_slice()) { (Some(n), _) => { - let matched: Vec<_> = imp.actions.iter().filter(|action| action.name == n).collect(); + let matched: Vec<_> = imp.actions.iter().enumerate().filter(|(_, action)| action.name == n).collect(); match matched.as_slice() { - [only] => *only, + [(index, _)] => *index, [] => return Err(ActionsError::NoSuchAtnAction { name: n.to_owned(), set: imp.set.clone(), names: names(&imp.actions) }), _ => { return Err(ActionsError::AmbiguousAtnAction { name: n.to_owned(), - sets: matched.iter().map(|action| action.set.clone().unwrap_or_else(|| imp.set.clone())).collect(), + sets: matched.iter().map(|(_, action)| action.set.clone().unwrap_or_else(|| imp.set.clone())).collect(), }); } } } - (None, [only]) => only, + (None, [_]) => 0, (None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }), (None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }), }; + let action = &imp.actions[selected]; let warnings = imp .unsupported .iter() @@ -135,6 +136,7 @@ pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec< .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) .chain(imp.warnings.iter().cloned()) .collect(); - let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + photocraft_engine::atn_cmds::normalize_import(&mut imp, std::iter::empty()).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; + let play = vec![("actions.play".to_string(), json!({"action": imp.actions[selected].name}))]; Ok((play, imp.actions, warnings)) } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 5ec18717fd7..ddaa8d62529 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1871,3 +1871,70 @@ fn batch_external_atn_input_is_not_limited_by_the_preset_store_size() { assert_eq!(actions[0].steps[0].0, photocraft_engine::atn_cmds::PHOTOSHOP_STEP); assert_eq!(warnings.len(), 1); } + +/// CLI import must bind a nested call to its own physical set before Batch playback. +#[test] +fn batch_nested_atn_calls_keep_physical_set_targets() { + use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; + use photocraft_psd::{atn, descriptor::UnicodeString}; + for explicit in [false, true] { + let directory = tmp(if explicit { "nested-atn-explicit-set" } else { "nested-atn-local-set" }); + let file = directory.join("sets.atn"); + let marker = |name: &str| Action::new("Finish", vec![("layer.new.layer".into(), json!({"name":name}))]); + let caller = + Action::new("Caller", vec![("actions.play".into(), if explicit { json!({"action":"Finish","set":"A"}) } else { json!({"action":"Finish"}) })]); + let group = |name: &str, actions: &[&Action]| { + let bytes = atn_cmds::export(name, actions, &[], false).unwrap().0; + atn::parse(&bytes).unwrap() + }; + let first = marker("From A"); + let second = marker("From B"); + let files = vec![group("A", &[&first]), group("B", &[&second, &caller])]; + assert_eq!(files[1].name, UnicodeString::new_nul("B")); + std::fs::write(&file, atn::write_sets(&files).unwrap()).unwrap(); + let (steps, actions, _) = photocraft_cli::load_actions_file(&file, Some("Caller")).unwrap(); + let mut session = Session::new(); + session.execute("file.new", json!({"width":4,"height":4})).unwrap(); + session.actions.list = actions; + for (id, params) in steps { + let result = session.execute(&id, params).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + } + let expected = if explicit { "From A" } else { "From B" }; + assert!(session.active().unwrap().doc.layers.iter().any(|layer| layer.name == expected)); + assert!(!session.active().unwrap().doc.layers.iter().any(|layer| layer.name == if explicit { "From B" } else { "From A" })); + } +} + +/// An unscoped cross-set call with no local candidate must fail before output creation. +#[test] +fn batch_refuses_ambiguous_nested_atn_calls_before_output() { + use photocraft_engine::{actions_cmds::Action, atn_cmds}; + use photocraft_psd::atn; + let directory = tmp("nested-atn-ambiguous"); + let file = directory.join("sets.atn"); + let finish = Action::new("Finish", vec![("layer.new.layer".into(), json!({}))]); + let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action":"Finish"}))]); + let mut groups = Vec::new(); + for (name, action) in [("A", &finish), ("B", &finish), ("C", &caller)] { + let bytes = atn_cmds::export(name, &[action], &[], false).unwrap().0; + groups.push(atn::parse(&bytes).unwrap()); + } + std::fs::write(&file, atn::write_sets(&groups).unwrap()).unwrap(); + let output = directory.join("out"); + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let args = [ + "batch".as_ref(), + "--in".as_ref(), + directory.as_os_str(), + "--out".as_ref(), + output.as_os_str(), + "--actions".as_ref(), + file.as_os_str(), + "--action".as_ref(), + "Caller".as_ref(), + ]; + assert_eq!(photocraft_cli::run(args, &mut stdout, &mut stderr), 1); + assert!(String::from_utf8_lossy(&stderr).contains("ambiguous cross-set")); + assert!(!output.exists()); +} diff --git a/assets/icons/action-editor/README.md b/assets/icons/action-editor/README.md index 5e77b86582a..fa8b2960779 100644 --- a/assets/icons/action-editor/README.md +++ b/assets/icons/action-editor/README.md @@ -9,9 +9,9 @@ Each source uses a 24 px viewBox, currentColor and the existing 2 px round strok convention. The existing SvgIconSet pipeline recolours and overrides stroke width at display time. No new renderer or dependencies are needed. -The mapping is in `crates/ui-egui/src/panels/actions/editor_glyphs.rs`. It is not -registered in production yet. Add its consumer and the thirty names to the existing -embedded icon table together. Preserve each raw metadata identifier on save; +The mapping in `crates/ui-egui/src/panels/actions/editor_glyphs.rs` is registered +through `icon_data.rs` and used by the editor picker and modern Actions rows. +Preserve each raw metadata identifier on save; unknown identifiers display the existing neutral `square` icon, whose Lucide and Feather attribution remains in the parent attribution table. diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 9bbaacb43dc..a18add81c06 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -56,7 +56,7 @@ mod tables; #[cfg(test)] use adjustments::{levels_from, selective_from}; -pub use convert::{Imported, ImportedSet, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key}; +pub use convert::{Imported, ImportedSet, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, normalize_import, parse_function_key}; #[cfg(test)] use convert::{MAX_NAME_CHARS, export_item, normalise}; #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 348614c2a9d..2d3535f953c 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -43,6 +43,78 @@ pub struct ImportedSet { pub(crate) metadata: Option>, } +/// Give imported actions globally unique playback names while keeping calls bound to their physical set. +/// Explicit set references win over caller-local names; ambiguous cross-set calls are refused. +/// The caller owns the uncommitted import, so a refusal never changes a live library. +pub fn normalize_import(imp: &mut Imported, reserved: impl IntoIterator) -> std::result::Result, String> { + let mut set_for = vec![0usize; imp.actions.len()]; + for (index, set) in imp.sets.iter().enumerate() { + set_for.get_mut(set.actions.clone()).ok_or_else(|| "invalid imported set range".to_string())?.fill(index); + } + if imp.action_keys.len() != imp.actions.len() { + return Err("invalid imported shortcut positions".to_string()); + } + let mut taken = UniqueNames::new(reserved); + let mut names = Vec::with_capacity(imp.actions.len()); + let mut renamed: Vec> = vec![Default::default(); imp.sets.len()]; + let mut global: std::collections::HashMap> = std::collections::HashMap::new(); + let mut added = Vec::with_capacity(imp.actions.len()); + for (index, mut a) in std::mem::take(&mut imp.actions).into_iter().enumerate() { + let name = taken.claim(&a.name); + let group = set_for.get(index).and_then(|set| renamed.get_mut(*set)).ok_or_else(|| "invalid imported action position".to_string())?; + group.entry(a.name.clone()).or_insert_with(|| name.clone()); + global + .entry(a.name.clone()) + .and_modify(|entry| { + if entry.as_ref() != Some(&name) { + *entry = None; + } + }) + .or_insert(Some(name.clone())); + a.name = name.clone(); + names.push(name); + added.push(a); + } + // Resolve imported calls within their physical set first; never use another set's first + // duplicate display name for metadata or shortcuts. Ambiguous cross-set calls fail before commit. + for (index, a) in added.iter_mut().enumerate() { + let local = set_for.get(index).and_then(|set| renamed.get(*set)).ok_or_else(|| "invalid imported action position".to_string())?; + for (id, params) in &mut a.steps { + let keys: &[&str] = match id.as_str() { + "actions.play" => &["action"], + "actions.conditional" => &["then", "else"], + _ => &[], + }; + for key in keys { + if let Some(target) = params.get(*key).and_then(Value::as_str) { + let scope = params + .get(format!("{key}Set")) + .or_else(|| params.get("set")) + .and_then(Value::as_str) + .and_then(|name| imp.sets.iter().position(|set| set.name == name)) + .and_then(|index| renamed.get(index)) + .unwrap_or(local); + let to = if let Some(to) = scope.get(target) { + Some(to) + } else { + match global.get(target) { + Some(Some(to)) => Some(to), + Some(None) => return Err("ambiguous cross-set action call; source set is required".to_string()), + None => None, + } + }; + if let Some(to) = to { + params[*key] = json!(to); + } + } + } + } + } + imp.actions = added; + imp.function_keys = names.iter().zip(&imp.action_keys).filter_map(|(name, key)| key.as_ref().map(|key| (name.clone(), key.clone()))).collect(); + Ok(names) +} + /// Retain an unsupported event's ID kind and descriptor for later export without executing it. fn photoshop_step(item: &AtnItem, label: String) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); @@ -80,6 +152,13 @@ pub(super) fn clip_name(s: &str) -> String { s.chars().take(MAX_NAME_CHARS).collect() } +/// The durable library retains all native bytes; external files accept the recorder's command surface. +#[derive(Clone, Copy)] +enum NativeInput { + Library, + Portable, +} + /// Project events under archive-wide step limits, retaining flags and optional original-event cohorts. fn convert_action( file: &AtnFile, @@ -88,13 +167,14 @@ fn convert_action( unsupported: &mut Vec, total: &mut usize, mut cohorts: Option<&mut Vec>>, + native_input: NativeInput, ) -> std::result::Result { let name = clip_name(&a.name.to_string_lossy()); let mut action = Action::new(name.clone(), Vec::new()); action.set = Some(clip_name(set)); action.color = color_from_atn(a.color); for (i, item) in a.items.iter().enumerate() { - let native = read_native(item)?; + let native = read_native(item, native_input)?; let is_native = native.is_some(); let mapped = if let Some(step) = native { Mapped::Steps(vec![step]) @@ -149,7 +229,7 @@ pub(crate) fn library_action(file: &AtnFile, action: &AtnAction, set: &str) -> s let mut cohorts = Vec::new(); let mut unsupported = Vec::new(); let mut total = 0; - let mut value = convert_action(file, action, set, &mut unsupported, &mut total, Some(&mut cohorts))?; + let mut value = convert_action(file, action, set, &mut unsupported, &mut total, Some(&mut cohorts), NativeInput::Library)?; value.name = action.name.to_string_lossy(); value.set = (!set.is_empty()).then(|| set.to_string()); // The durable library reflects its wire flags. The external importer may deliberately @@ -180,7 +260,7 @@ fn native_text(descriptor: &Descriptor, key: &str) -> std::result::Result std::result::Result, String> { +fn read_native(item: &AtnItem, input: NativeInput) -> std::result::Result, String> { if item.event != AtnEvent::name(NATIVE_EVENT) { return Ok(None); } @@ -192,6 +272,18 @@ fn read_native(item: &AtnItem) -> std::result::Result, S if id.is_empty() { return Err("empty native action command".into()); } + // Use the same command eligibility as recording, plus the explicit playback controls. + // Configuration/install commands cannot be smuggled through a portable Actions file. + // Unknown/future IDs remain preservable and fail normal dispatch without a side effect. + // Re-importing after an upgrade checks any newly registered command against this policy. + // Local persistence retains every native step without executing it. + if matches!(input, NativeInput::Portable) + && crate::commands::find(&id).is_some() + && !crate::actions_cmds::replayable(&id) + && !matches!(id.as_str(), super::STOP_MESSAGE | super::PHOTOSHOP_STEP | "actions.conditional") + { + return Err(format!("native action command `{id}` is not permitted in a portable Actions file")); + } let params = native_text(descriptor, "params")?; // serde's bounded recursion remains enabled; load never dispatches or resolves the command. let params = serde_json::from_str(¶ms).map_err(|error| format!("invalid native action JSON: {error}"))?; @@ -274,7 +366,7 @@ pub fn import_with_encoding(bytes: &[u8], encoding: Option<&str>) -> std::result } let start = out.actions.len(); for (at, action) in file.actions.iter().enumerate() { - let mut converted = convert_action(file, action, &set, &mut out.unsupported, &mut total, None)?; + let mut converted = convert_action(file, action, &set, &mut out.unsupported, &mut total, None, NativeInput::Portable)?; if let Some(value) = metadata.actions.get_mut(index).and_then(|actions| actions.get_mut(at)).and_then(Option::take) { super::sidecar::apply(&mut converted, value); } diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 754dd431417..00bccafcd2a 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -1,7 +1,8 @@ use super::convert::{export_groups, import_with_encoding}; use super::sidecar; -use super::{Action, DEFAULT_SET, EngineError, Result, Session, UniqueNames, Value, bad, json}; +use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json}; +/// Validate and normalize an external archive before committing actions, physical rows and shortcuts. pub(super) fn load(s: &mut Session, p: &Value) -> Result { let cmd = "actions.load"; if s.actions.recording_active() || s.actions.playing > 0 { @@ -93,74 +94,10 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { if old_rows.checked_add(new_rows).is_none_or(|count| count > super::atn::MAX_SETS) { return Err(bad(cmd, "too many action set rows")); } - let mut set_for = vec![0usize; imp.actions.len()]; - for (index, set) in imp.sets.iter().enumerate() { - set_for.get_mut(set.actions.clone()).ok_or_else(|| bad(cmd, "invalid imported set range"))?.fill(index); - } - if imp.action_keys.len() != imp.actions.len() { - return Err(bad(cmd, "invalid imported shortcut positions")); - } - let mut taken = UniqueNames::new(s.actions.list.iter().filter(keep).map(|action| action.name.clone())); - let mut names = Vec::with_capacity(imp.actions.len()); - let mut renamed: Vec> = vec![Default::default(); imp.sets.len()]; - let mut global: std::collections::HashMap> = std::collections::HashMap::new(); - let mut steps = 0usize; - let mut added = Vec::with_capacity(imp.actions.len()); - let mut keys = Vec::new(); - for (index, mut a) in imp.actions.into_iter().enumerate() { - let name = taken.claim(&a.name); - let group = set_for.get(index).and_then(|set| renamed.get_mut(*set)).ok_or_else(|| bad(cmd, "invalid imported action position"))?; - group.entry(a.name.clone()).or_insert_with(|| name.clone()); - global - .entry(a.name.clone()) - .and_modify(|entry| { - if entry.as_ref() != Some(&name) { - *entry = None; - } - }) - .or_insert(Some(name.clone())); - if let Some(key) = imp.action_keys.get(index).and_then(Option::as_ref) { - keys.push((name.clone(), key.clone())); - } - a.name = name.clone(); - steps = steps.saturating_add(a.steps.len()); - names.push(name); - added.push(a); - } - // Resolve imported calls within their physical set first; never use another set's first - // duplicate display name for metadata or shortcuts. Ambiguous cross-set calls fail before commit. - for (index, a) in added.iter_mut().enumerate() { - let local = set_for.get(index).and_then(|set| renamed.get(*set)).ok_or_else(|| bad(cmd, "invalid imported action position"))?; - for (id, params) in &mut a.steps { - let keys: &[&str] = match id.as_str() { - "actions.play" => &["action"], - "actions.conditional" => &["then", "else"], - _ => &[], - }; - for key in keys { - if let Some(target) = params.get(*key).and_then(Value::as_str) { - let scope = params - .get("set") - .and_then(Value::as_str) - .and_then(|name| imp.sets.iter().position(|set| set.name == name)) - .and_then(|index| renamed.get(index)) - .unwrap_or(local); - let to = if let Some(to) = scope.get(target) { - Some(to) - } else { - match global.get(target) { - Some(Some(to)) => Some(to), - Some(None) => return Err(bad(cmd, "ambiguous cross-set action call; source set is required")), - None => None, - } - }; - if let Some(to) = to { - params[*key] = json!(to); - } - } - } - } - } + let names = super::normalize_import(&mut imp, s.actions.list.iter().filter(keep).map(|action| action.name.clone())).map_err(|error| bad(cmd, error))?; + let keys = imp.function_keys.clone(); + let steps: usize = imp.actions.iter().map(|action| action.steps.len()).sum(); + let added = imp.actions; let set_names: Vec = imp.sets.iter().map(|set| set.name.clone()).collect(); if replace { s.actions.list.retain(|action| !targets.contains(super::logical_set_name(action.set.as_deref()))); @@ -200,6 +137,7 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { })) } +/// Encode the selected action or library while retaining its physical source and metadata. pub(super) fn save(s: &mut Session, p: &Value) -> Result { let cmd = "actions.save"; crate::actions_cmds::flush_recording(s)?; diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index c0c442f4433..c294a9fded5 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -110,9 +110,16 @@ pub(super) fn conditional_from(d: &Descriptor) -> Mapped { Some(Target::Name(n)) => Some(clip_name(&n)), _ => None, }; - step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) + let mut params = json!({"if": condition, "then": branch("then"), "else": branch("else")}); + for key in ["then", "else"] { + if let Some(set) = get_ref(d, key).and_then(|reference| super::steps_layer::action_set(reference)) { + params[format!("{key}Set")] = json!(set); + } + } + step("actions.conditional", params) } +/// Write independent branch references, retaining each explicit set before the shared fallback set. pub(super) fn conditional_to(_: &str, p: &Value) -> Option { let condition = jstr(p, "if")?; let code = lookup_back(CONDITION_CODES, condition).unwrap_or(condition); @@ -120,7 +127,7 @@ pub(super) fn conditional_to(_: &str, p: &Value) -> Option { for key in ["then", "else"] { if let Some(name) = jstr(p, key) { let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; - if let Some(set) = jstr(p, "set") { + if let Some(set) = p.get(format!("{key}Set")).and_then(Value::as_str).or_else(|| jstr(p, "set")) { items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); } d = d.with(key, D::Reference(items)); diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index ed1d9caabbd..4ffa6047a36 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -453,13 +453,30 @@ pub(super) fn apply_comp_to(_: &str, p: &Value) -> Option { Some(desc("null").with("null", reference)) } +/// Preserve the named physical set carried beside an Actn reference. +pub(super) fn action_set(reference: &[ReferenceItem]) -> Option { + reference.iter().find_map(|item| match item { + ReferenceItem::Name { class, name } if class.class_id.is("ASet") => Some(clip_name(&name.to_string_lossy())), + _ => None, + }) +} + +/// Decode the named action and its set together so later name normalization cannot misbind a call. pub(super) fn play_from(d: &Descriptor) -> Mapped { - match get_ref(d, "null").map(target) { - Some(Target::Name(n)) => step("actions.play", json!({"action": clip_name(&n)})), + let Some(reference) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + match target(reference) { + Target::Name(name) => { + let mut params = json!({"action": clip_name(&name)}); + if let Some(set) = action_set(reference) { + params["set"] = json!(set); + } + step("actions.play", params) + } _ => Mapped::Unsupported { harmless: false }, } } +/// Write a complete named action reference; partial playback cannot be represented by this event. pub(super) fn play_to(_: &str, p: &Value) -> Option { let name = jstr(p, "action")?; // The Play Action descriptor written here carries no starting step, so a call from a later step is not a whole call. diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 3876cb92bd9..41dcf6c9681 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -11,7 +11,7 @@ use photocraft_psd::{ use serde_json::json; /// The durable writer preserves explicit set scopes through its exact native fallback. -/// Portable Photoshop projection may omit those scopes; it is not this loader fixture's input. +/// Portable Play/Conditional reference scopes have separate CLI and import controls. fn row(name: &str, mut actions: Vec) -> AtnFile { if actions.is_empty() { return AtnFile { name: UnicodeString::new_nul(name), ..Default::default() }; @@ -59,7 +59,6 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( ]; if let Some(scope) = scope { let caller = files[3].actions.iter().find(|action| action.name.to_string_lossy() == "Caller").unwrap(); - assert_eq!(caller.items[0].event, atn::AtnEvent::name("photocraftCommand"), "fixture must retain the recorded set"); let bytes = atn::write(&files[3]).unwrap(); let imported = photocraft_engine::atn_cmds::import(&bytes).unwrap(); assert_eq!(imported.actions[0].steps[0].1["set"], scope); @@ -96,3 +95,30 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( } } } + +/// Portable Conditional references may name a different physical set for each branch. +#[test] +fn portable_conditional_branches_retain_independent_set_scopes() { + use photocraft_engine::atn_cmds; + for (condition, expected) in [("hasLayers", "From A"), ("hasSelection", "From B")] { + let caller = + Action::new("Caller", vec![("actions.conditional".into(), json!({"if":condition,"then":"Finish","thenSet":"A","else":"Finish","elseSet":"B"}))]); + let mut rows = Vec::new(); + let first = finish("From A"); + let second = finish("From B"); + for (name, action) in [("A", &first), ("B", &second), ("C", &caller)] { + let bytes = atn_cmds::export(name, &[action], &[], false).unwrap().0; + rows.push(atn::parse(&bytes).unwrap()); + } + let bytes = atn::write_sets(&rows).unwrap(); + let projected = atn_cmds::import(&bytes).unwrap(); + assert_eq!(projected.actions[2].steps[0].1["thenSet"], "A"); + assert_eq!(projected.actions[2].steps[0].1["elseSet"], "B"); + let mut session = Session::new(); + session.execute("file.new", json!({"width":4,"height":4})).unwrap(); + session.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap(); + let result = session.execute("actions.play", json!({"action":"Caller"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(session.active().unwrap().doc.layers.last().unwrap().name, expected); + } +} diff --git a/crates/engine/tests/it/atn_native_admission.rs b/crates/engine/tests/it/atn_native_admission.rs new file mode 100644 index 00000000000..d30b19e1fc6 --- /dev/null +++ b/crates/engine/tests/it/atn_native_admission.rs @@ -0,0 +1,46 @@ +//! Native portable-file eligibility and lossless local library storage are distinct contracts. +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds, + preset_store::{self, ACTIONS_FILE, MAX_ACTIONS_BYTES, MemBackend, PresetBackend}, +}; +use serde_json::json; + +/// Serialize through the actual durable writer, including its native fallback. +fn encode(action: &Action) -> Vec { + let store = MemBackend::default(); + let mut session = Session::new(); + assert!(session.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + session.actions.list.push(action.clone()); + session.actions.rev += 1; + session.sync_preset_store(); + store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap() +} + +/// Portable Actions files cannot install code or run script wrappers; local bytes remain lossless. +#[test] +fn portable_native_admission_preserves_library_bytes_but_refuses_nonrecordable_effects() { + for id in ["plugin.install", "file.scripts.browse"] { + let action = Action::new("Untrusted", vec![(id.into(), json!({"path":"never-opened.wasm","data":"never-installed"}))]); + let bytes = encode(&action); + let store = MemBackend::default(); + store.write(ACTIONS_FILE, &bytes).unwrap(); + let mut reopened = Session::new(); + assert!(reopened.attach_preset_store(preset_store::open(Box::new(store.clone()))).is_empty()); + assert_eq!(reopened.actions.list[0].steps, action.steps, "local library remains a complete store"); + assert_eq!(store.read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap(), bytes); + let error = atn_cmds::import(&bytes).unwrap_err(); + assert!(error.contains(id) && error.contains("not permitted"), "{error}"); + let mut session = Session::new(); + let before = session.actions.clone(); + assert!(session.execute("actions.load", json!({"data":crate::paint::tile::b64_encode(&bytes)})).is_err()); + assert_eq!(session.actions, before, "refusal occurs before installing any action"); + } + for (id, params) in [("filter.distort.ripple", json!({"amount":1.5,"size":"medium"})), ("file.save", json!({"path":"recorded.png","nativeOption":true}))] { + let action = Action::new("Recorded", vec![(id.into(), params)]); + let bytes = encode(&action); + let imported = atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps, action.steps, "required native command+JSON and legitimate saves remain usable"); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 029dd85b549..e16fa5213d3 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -15,6 +15,7 @@ mod atn_call_scopes; mod atn_crystallize_cell_size; mod atn_direct_adjustments; mod atn_layer_style; +mod atn_native_admission; mod atn_newer_events; mod atn_reference_forms; mod atn_ripple_amount; diff --git a/crates/format/src/noreplace.rs b/crates/format/src/noreplace.rs index b43434d9bdb..5e6006391c3 100644 --- a/crates/format/src/noreplace.rs +++ b/crates/format/src/noreplace.rs @@ -1,12 +1,13 @@ //! No-clobber retirement of a legacy file beside its replacement. //! -//! This is one rename, not a transaction with the preceding ATN publication. A caller must +//! Prefer a native exclusive rename, with atomic hard-link publication on unsupported Unix +//! targets/kernels. This is not a transaction with the preceding ATN publication. A caller must //! preserve/report an incomplete migration if the operation fails. In particular, never //! replace this with an existence check followed by an overwriting rename. use std::{io, path::Path}; -/// Publication failure distinguishes an unchanged destination from an unconfirmed directory flush. +/// Publication failure distinguishes an unchanged destination from a published file whose cleanup or directory flush failed. #[derive(Debug)] pub struct PublishNewError { pub published: bool, @@ -15,7 +16,7 @@ pub struct PublishNewError { impl std::fmt::Display for PublishNewError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.published { write!(f, "file published; directory durability unconfirmed: {}", self.error) } else { std::fmt::Display::fmt(&self.error, f) } + if self.published { write!(f, "file published; completion unconfirmed: {}", self.error) } else { std::fmt::Display::fmt(&self.error, f) } } } impl std::error::Error for PublishNewError {} @@ -46,24 +47,69 @@ pub fn write_noreplace(path: &Path, bytes: &[u8]) -> Result<(), PublishNewError> let (temp, mut file) = owned.ok_or_else(|| before(io::Error::new(io::ErrorKind::AlreadyExists, "no unused publication temp name")))?; let written = file.write_all(bytes).and_then(|_| crate::atomic::sync_file(&file)); drop(file); - let published = written.and_then(|_| rename_noreplace(&temp, path)); - if let Err(error) = published { + let published = written + .map_err(before) + .and_then(|()| move_noreplace(&temp, path, platform, |from, to| std::fs::hard_link(from, to), |path| std::fs::remove_file(path))); + if published.is_err() { let _ = std::fs::remove_file(&temp); - return Err(before(error)); } - RealFs.sync_dir(dir).map_err(|error| PublishNewError { published: true, error }) + finish_publication(published, || RealFs.sync_dir(dir)) } /// Rename within one directory, refusing an existing destination atomically. -/// No fallback overwrites a backup or copies across volumes. Unsupported kernels/filesystems -/// return their error so the caller can keep both the ATN and original legacy input. +/// Unsupported rename primitives use a hard link followed by unlink, never an overwriting +/// rename or cross-volume copy. If unlink fails, both names retain the same bytes and this +/// returns an error, so the caller reports retirement as incomplete. /// The caller must flush the directory before reporting crash-durable migration completion; /// the existing `atomic::AtomicFs::sync_dir` provides that separately fallible operation. pub fn rename_noreplace(from: &Path, to: &Path) -> io::Result<()> { if from == to || from.parent() != to.parent() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "no-replace retirement requires distinct names in one directory")); } - platform(from, to) + move_noreplace(from, to, platform, |from, to| std::fs::hard_link(from, to), |path| std::fs::remove_file(path)).map_err(|failure| failure.error) +} + +/// Use fallback only when the exclusive rename primitive itself is unsupported. +fn unsupported_rename(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::Unsupported { + return true; + } + #[cfg(unix)] + if matches!(error.raw_os_error(), Some(code) if code == rustix::io::Errno::INVAL.raw_os_error() || code == rustix::io::Errno::NOSYS.raw_os_error()) { + return true; + } + false +} + +/// Linking claims an absent destination atomically; a later unlink failure is already published. +fn move_noreplace( + from: &Path, + to: &Path, + rename: impl FnOnce(&Path, &Path) -> io::Result<()>, + link: impl FnOnce(&Path, &Path) -> io::Result<()>, + remove: impl FnOnce(&Path) -> io::Result<()>, +) -> Result<(), PublishNewError> { + match rename(from, to) { + Ok(()) => Ok(()), + Err(error) if unsupported_rename(&error) => { + link(from, to).map_err(|error| PublishNewError { published: false, error })?; + remove(from).map_err(|error| PublishNewError { published: true, error }) + } + Err(error) => Err(PublishNewError { published: false, error }), + } +} + +/// A successful name change is durable only after the separately fallible directory flush. +fn finish_publication(result: Result<(), PublishNewError>, sync: impl FnOnce() -> io::Result<()>) -> Result<(), PublishNewError> { + match result { + Ok(()) => sync().map_err(|error| PublishNewError { published: true, error }), + Err(failure) => { + if failure.published { + let _ = sync(); + } + Err(failure) + } + } } #[cfg(windows)] @@ -86,7 +132,7 @@ fn platform(_from: &Path, _to: &Path) -> io::Result<()> { Err(io::Error::new(io::ErrorKind::Unsupported, "this platform has no supported no-replace retirement primitive")) } -#[cfg(all(test, any(windows, all(unix, any(target_os = "linux", target_os = "android", target_os = "redox", target_vendor = "apple")))))] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use std::{ @@ -157,4 +203,64 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), b"complete ATN"); assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 1); } + + /// An existing destination survives both the fallback and the native primitive unchanged. + #[test] + fn fallback_is_exclusive_and_moves_complete_bytes() { + let dir = Dir::new().unwrap(); + let (from, to) = (dir.0.join("legacy"), dir.0.join("backup")); + std::fs::write(&from, b"original").unwrap(); + std::fs::write(&to, b"existing").unwrap(); + let unsupported = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::Unsupported)); + let failed = move_noreplace(&from, &to, unsupported, |from, to| std::fs::hard_link(from, to), |path| std::fs::remove_file(path)).unwrap_err(); + assert!(!failed.published); + assert_eq!(std::fs::read(&to).unwrap(), b"existing"); + assert_eq!(std::fs::read(&from).unwrap(), b"original"); + std::fs::remove_file(&to).unwrap(); + move_noreplace(&from, &to, unsupported, |from, to| std::fs::hard_link(from, to), |path| std::fs::remove_file(path)).unwrap(); + assert!(!from.exists()); + assert_eq!(std::fs::read(&to).unwrap(), b"original"); + } + + /// A published destination cannot be called unchanged when source cleanup or fsync fails. + #[test] + fn fallback_unlink_and_directory_flush_failures_report_publication() { + let dir = Dir::new().unwrap(); + let (from, to) = (dir.0.join("legacy"), dir.0.join("backup")); + std::fs::write(&from, b"original").unwrap(); + let error = move_noreplace( + &from, + &to, + |_, _| Err(io::ErrorKind::Unsupported.into()), + |from, to| std::fs::hard_link(from, to), + |_| Err(io::ErrorKind::PermissionDenied.into()), + ) + .unwrap_err(); + assert!(error.published); + assert_eq!(std::fs::read(&from).unwrap(), b"original"); + assert_eq!(std::fs::read(&to).unwrap(), b"original"); + let error = finish_publication(Err(error), || Ok(())).unwrap_err(); + assert!(error.published); + assert_eq!(error.error.kind(), io::ErrorKind::PermissionDenied); + let error = finish_publication(Ok(()), || Err(io::ErrorKind::Other.into())).unwrap_err(); + assert!(error.published); + assert_eq!(std::fs::read(&to).unwrap(), b"original"); + } + + /// Ordinary rename failures must not trigger a second filesystem mutation route. + #[test] + fn ordinary_rename_errors_do_not_attempt_fallback() { + for kind in [io::ErrorKind::AlreadyExists, io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound] { + let result = move_noreplace( + Path::new("from"), + Path::new("to"), + |_, _| Err(kind.into()), + |_, _| panic!("ordinary failure must not link"), + |_| panic!("ordinary failure must not unlink"), + ); + let error = result.unwrap_err(); + assert!(!error.published); + assert_eq!(error.error.kind(), kind); + } + } } diff --git a/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs b/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs index f69d80915b3..395f42ae82c 100644 --- a/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs +++ b/crates/ui-egui/src/panels/actions/editor_glyph_tests.rs @@ -6,7 +6,7 @@ use craft_ui::icons::{SvgIconSet, SvgStyle}; use glyphs::{GLYPH_COLUMNS, GLYPHS, display_glyph}; -// Test-only embed table. Production registration belongs with the real editor consumer. +// Test-only embed table; production assets are registered in icon_data.rs. static ASSETS: &[(&str, &[u8])] = &[ ("action-editor-click", include_bytes!("../../../../../assets/icons/action-editor/click.svg")), ("action-editor-crop", include_bytes!("../../../../../assets/icons/action-editor/crop.svg")), diff --git a/crates/ui-egui/src/panels/actions/editor_glyphs.rs b/crates/ui-egui/src/panels/actions/editor_glyphs.rs index 6bf2125fc7f..b3cfc8607c6 100644 --- a/crates/ui-egui/src/panels/actions/editor_glyphs.rs +++ b/crates/ui-egui/src/panels/actions/editor_glyphs.rs @@ -1,6 +1,6 @@ //! Display names for the measured Actions editor glyph identifiers (#10946). //! -//! Source preparation: include this module in the real editor when its consumer is added. +//! The editor picker and modern Actions rows use the assets registered in `icon_data.rs`. //! Lookup is exact and display-only. Keep the original metadata identifier when saving. /// The picker uses six columns, with entries ordered row by row. diff --git a/crates/ui-egui/src/panels/actions/editor_picker.rs b/crates/ui-egui/src/panels/actions/editor_picker.rs index a8c27700c40..a91a56cb39b 100644 --- a/crates/ui-egui/src/panels/actions/editor_picker.rs +++ b/crates/ui-egui/src/panels/actions/editor_picker.rs @@ -142,8 +142,8 @@ fn contents(ui: &mut egui::Ui, icon: &str, color: &str, swatches: &[Color32; 12] /// `id` scopes popup/widget state for this editor. `icon`/`color` are authoritative raw values; /// unknowns display neutrally and are never returned as replacements without a click. `swatches` /// are caller-selected PhotoCraft colours in ATN index order, not measured reference RGB samples. -/// Slot zero is ignored in favour of the neutral theme surface. Register the thirty glyph assets -/// through the existing icon table when connecting this leaf to the actual editor consumer. +/// Slot zero uses the neutral theme surface. The editor and modern rows share the thirty +/// glyph assets registered in the existing icon table. pub fn show(ui: &mut egui::Ui, id: Id, icon: &str, color: &str, swatches: &[Color32; 12]) -> Option { let tokens = Tokens::get(ui.ctx()); ui.push_id(id, |ui| { diff --git a/docs/atn.md b/docs/atn.md index dcbaaecc332..459164a2579 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -2169,3 +2169,12 @@ Copy and Paste Layer Style (`copyEffects`, `pasteEffects`), Align (`align`), Ren layer's name), Deselect (Set of the selection to None). Steps recorded with Backlight, Fill Flash, Color Cast, Variations, Texture Fill and the old Snapshot events come only from very old versions and stay kept steps. + + +Native commands in imported files follow the action recorder's command eligibility. +Registered configuration commands, including plug-in installation and script wrappers, +are refused before the imported library is installed. Replayable edits, recorded file +operations, and playback controls remain available. Unknown command IDs can be retained +for future versions but fail normal playback dispatch while unsupported. The local ATN +preset library preserves all native command IDs and JSON parameters without executing +steps during load. From a564d96f9886ed0994b987071fbbf240205fa474 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:21:15 -0800 Subject: [PATCH 122/384] Correct the native admission integration test import --- crates/engine/tests/it/atn_native_admission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/it/atn_native_admission.rs b/crates/engine/tests/it/atn_native_admission.rs index d30b19e1fc6..883eadd42f4 100644 --- a/crates/engine/tests/it/atn_native_admission.rs +++ b/crates/engine/tests/it/atn_native_admission.rs @@ -34,7 +34,7 @@ fn portable_native_admission_preserves_library_bytes_but_refuses_nonrecordable_e assert!(error.contains(id) && error.contains("not permitted"), "{error}"); let mut session = Session::new(); let before = session.actions.clone(); - assert!(session.execute("actions.load", json!({"data":crate::paint::tile::b64_encode(&bytes)})).is_err()); + assert!(session.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).is_err()); assert_eq!(session.actions, before, "refusal occurs before installing any action"); } for (id, params) in [("filter.distort.ripple", json!({"amount":1.5,"size":"medium"})), ("file.save", json!({"path":"recorded.png","nativeOption":true}))] { From 73e625882c21a0a47bd4a7822b61906327fb8d83 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:25:31 -0800 Subject: [PATCH 123/384] Document playback and import invariants for review --- apps/photocraft-cli/src/actions.rs | 1 + crates/engine/src/cmd/actions_cmds.rs | 11 +++++++++++ crates/engine/src/cmd/atn_cmds/steps_doc.rs | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 6d9e7b7141f..651aba5ba85 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -58,6 +58,7 @@ pub fn parse_actions(text: &str) -> Result, ActionsError> { steps_of(&v) } +/// Read the actual step array from a droplet/action value and report malformed input before Batch starts. fn steps_of(v: &Value) -> Result { Ok(photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions")?) } diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index de2c235bb57..69373aec6dc 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -452,6 +452,7 @@ fn num(v: &Value) -> Option { v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64)) } +/// Resolve numeric identity or the first exact named definition, validating hostile indices and empty names. pub(crate) fn resolve(list: &[Action], p: &Value, cmd: &str) -> Result { let Some(v) = p.get("action") else { return Err(bad(cmd, "needs \"action\" (a name or an index)")); @@ -475,6 +476,7 @@ pub(crate) fn resolve(list: &[Action], p: &Value, cmd: &str) -> Result { } } +/// Validate an optional playback start step against the selected action before any command runs. fn from_step(p: &Value, len: usize) -> Result { let Some(v) = p.get("from") else { return Ok(0) }; if v.is_null() { @@ -510,6 +512,7 @@ fn get(s: &mut Session, p: &Value) -> Result { ) } +/// Start capture into the selected or newly created definition while refusing incompatible playback state. fn record(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.record")?; if s.actions.recording_active() { @@ -566,6 +569,7 @@ pub fn pending_steps(s: &Session, action: usize) -> impl Iterator Result<()> { let Some((_, idx)) = s.actions.recording else { return editor::flush(s) }; if idx >= s.actions.list.len() { @@ -640,6 +644,7 @@ fn fold_recorded_nudges(steps: impl IntoIterator) -> Vec out } +/// Finish an active capture and expose the resulting action without replaying its recorded commands. fn stop(s: &mut Session, _p: &Value) -> Result { not_playing(s, "actions.stop")?; let Some((_, idx)) = s.actions.recording else { @@ -671,6 +676,7 @@ pub fn saves_smart_contents(s: &Session, id: &str) -> bool { id == "file.save" && s.is_enabled(SAVE_CONTENTS) } +/// Execute enabled steps under the shared budget and authorizer, retaining the first nested failure. fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { let mut ran = 0u64; let mut failed = None; @@ -725,6 +731,7 @@ pub fn begin_playback(s: &mut Session, action: &Action) -> Result, action: &Action, from: usize) { s.actions.playback_stack.pop(); s.actions.playing = s.actions.playing.saturating_sub(1); @@ -760,6 +767,7 @@ pub fn nested_failure(id: &str, value: &Value) -> Option { }) } +/// Validate the selected definition and start position before suspending capture for nested playback. fn play(s: &mut Session, p: &Value) -> Result { let (action, from) = playback_plan(s, p)?; // actions.play journals its own named call, rather than using dispatch's journaled @@ -791,6 +799,7 @@ fn play(s: &mut Session, p: &Value) -> Result { }) } +/// Reject definition edits while an action playback still owns the library. pub(crate) fn not_playing(s: &Session, cmd: &str) -> Result<()> { if s.actions.playing > 0 || s.actions.held_playback.is_some() { Err(bad(cmd, "cannot edit actions during playback")) } else { Ok(()) } } @@ -850,6 +859,7 @@ pub fn moved_index(index: usize, from: usize, to: usize) -> usize { } } +/// Remove the resolved action while retaining the identities of unaffected definitions and calls. fn delete(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.delete")?; if p.get("set").is_some() { @@ -1060,6 +1070,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { Ok(json!({"action": name, "index": idx})) } +/// Validate the requested action colour before changing the stored definition. fn set_color(s: &mut Session, p: &Value) -> Result { let cmd = "actions.setColor"; not_playing(s, cmd)?; diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index c294a9fded5..ee80dafbdcb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -17,6 +17,7 @@ pub(super) fn place_from(d: &Descriptor) -> Mapped { step(if get_bool(d, "Lnkd") == Some(true) { "file.placeLinked" } else { "file.placeEmbedded" }, p) } +/// Encode supported Place options and its source reference as a Photoshop descriptor. pub(super) fn place_to(id: &str, p: &Value) -> Option { let mut d = desc("null").with("null", path_value(jstr(p, "path")?)).with("FTcs", enm("QCSt", "Qcsa")); if let Some(s) = jnum(p, "scale") { @@ -76,6 +77,7 @@ pub(super) fn save_from(d: &Descriptor) -> Mapped { step(if get_bool(d, "Cpy ") == Some(true) { "file.saveACopy" } else { "file.saveAs" }, json!({"path": path})) } +/// Translate supported Save choices into a descriptor without inventing absent file options. pub(super) fn save_to(id: &str, p: &Value) -> Option { if id == "file.save" { return Some(desc("null")); @@ -194,6 +196,7 @@ pub(super) fn export_from(d: &Descriptor) -> Mapped { step("file.export.saveForWebLegacy", p) } +/// Encode the export format and supported options, refusing values the event cannot represent. pub(super) fn export_to(_: &str, p: &Value) -> Option { // A step recorded with the document's own name keeps only its folder (#5305). let (path, in_folder) = match (jstr(p, "path").filter(|v| !v.is_empty()), jstr(p, "folder")) { @@ -589,6 +592,7 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { Some(d) } +/// Project a Photoshop crop rectangle and supported options into command parameters. pub(super) fn crop_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); if let Some(t) = get_obj(d, "To ") { @@ -615,6 +619,7 @@ pub(super) fn crop_from(d: &Descriptor) -> Mapped { step("image.crop", Value::Object(p)) } +/// Encode a supported crop while refusing output-size options this event cannot preserve. pub(super) fn crop_to(_: &str, p: &Value) -> Option { // The W x H x Resolution output options have no field here: refuse rather than export a plain // rectangle crop that keeps the old size and resolution on replay. From 3ed156b67d09b60da23028d5d7b602f1928790c8 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:29:52 -0800 Subject: [PATCH 124/384] Retain the shared-set conditional command shape --- crates/engine/src/cmd/actions_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 17 ++++++++++++++--- crates/engine/tests/it/atn_call_scopes.rs | 13 +++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 69373aec6dc..2e59e87fc3e 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -859,7 +859,7 @@ pub fn moved_index(index: usize, from: usize, to: usize) -> usize { } } -/// Remove the resolved action while retaining the identities of unaffected definitions and calls. +/// Delete the selected set, action or step with the appropriate playback and recording guards. fn delete(s: &mut Session, p: &Value) -> Result { not_playing(s, "actions.delete")?; if p.get("set").is_some() { diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index ee80dafbdcb..2c2badfc906 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -113,9 +113,20 @@ pub(super) fn conditional_from(d: &Descriptor) -> Mapped { _ => None, }; let mut params = json!({"if": condition, "then": branch("then"), "else": branch("else")}); - for key in ["then", "else"] { - if let Some(set) = get_ref(d, key).and_then(|reference| super::steps_layer::action_set(reference)) { - params[format!("{key}Set")] = json!(set); + let scopes = ["then", "else"].map(|key| (key, get_ref(d, key).and_then(|reference| super::steps_layer::action_set(reference)))); + let shared = scopes + .iter() + .filter(|(key, _)| params[*key].is_string()) + .find_map(|(_, set)| set.as_deref()) + .filter(|set| scopes.iter().all(|(key, scope)| !params[*key].is_string() || scope.as_deref() == Some(*set))); + if let Some(set) = shared { + // Preserve the existing command shape when every active branch has the same scope. + params["set"] = json!(set); + } else { + for (key, set) in scopes { + if let Some(set) = set { + params[format!("{key}Set")] = json!(set); + } } } step("actions.conditional", params) diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 41dcf6c9681..34cd5013bd6 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -122,3 +122,16 @@ fn portable_conditional_branches_retain_independent_set_scopes() { assert_eq!(session.active().unwrap().doc.layers.last().unwrap().name, expected); } } + +/// Retaining set references must not invent per-branch fields for the existing shared-set API. +#[test] +fn portable_conditional_shared_scope_keeps_the_existing_command_shape() { + use photocraft_engine::atn_cmds; + for other in [serde_json::Value::Null, json!("Else")] { + let params = json!({"if":"always","then":"Then","else":other,"set":"S"}); + let action = Action::new("Caller", vec![("actions.conditional".into(), params.clone())]); + let bytes = atn_cmds::export("S", &[&action], &[], false).unwrap().0; + let imported = atn_cmds::import(&bytes).unwrap(); + assert_eq!(imported.actions[0].steps[0].1, params); + } +} From 8f449e0e1f1c7f1ee1243a4e44606c8d2f6803c7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:31:42 -0800 Subject: [PATCH 125/384] Keep the caller fixture assertion without an unused binding --- crates/engine/tests/it/atn_call_scopes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 34cd5013bd6..667ef0e0d1d 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -58,7 +58,7 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( row("Empty", Vec::new()), ]; if let Some(scope) = scope { - let caller = files[3].actions.iter().find(|action| action.name.to_string_lossy() == "Caller").unwrap(); + assert!(files[3].actions.iter().any(|action| action.name.to_string_lossy() == "Caller")); let bytes = atn::write(&files[3]).unwrap(); let imported = photocraft_engine::atn_cmds::import(&bytes).unwrap(); assert_eq!(imported.actions[0].steps[0].1["set"], scope); From 866bfa5caef2fada6f346ff7e39ddc3efee53cca Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:33:56 -0800 Subject: [PATCH 126/384] Create the working layer required by the conditional fixture --- crates/engine/tests/it/atn_call_scopes.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 667ef0e0d1d..2344fb3dc8e 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -116,6 +116,7 @@ fn portable_conditional_branches_retain_independent_set_scopes() { assert_eq!(projected.actions[2].steps[0].1["elseSet"], "B"); let mut session = Session::new(); session.execute("file.new", json!({"width":4,"height":4})).unwrap(); + session.execute("layer.new.layer", json!({"name":"Existing working layer"})).unwrap(); session.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap(); let result = session.execute("actions.play", json!({"action":"Caller"})).unwrap(); assert!(result.get("failed").is_none(), "{result}"); From 4390d6d6d14d1918f8b3cf3c718aa537a1bbcfd2 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 09:35:51 -0800 Subject: [PATCH 127/384] Keep nullable branch scopes consistent with ATN export --- crates/engine/src/cmd/atn_cmds/convert.rs | 2 +- crates/engine/tests/it/atn_call_scopes.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 2d3535f953c..3b5b26d9302 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -89,8 +89,8 @@ pub fn normalize_import(imp: &mut Imported, reserved: impl IntoIterator Date: Wed, 4 Nov 2026 09:43:19 -0800 Subject: [PATCH 128/384] Use the named set-reference projection directly --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 2c2badfc906..e622daeb24e 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -113,7 +113,7 @@ pub(super) fn conditional_from(d: &Descriptor) -> Mapped { _ => None, }; let mut params = json!({"if": condition, "then": branch("then"), "else": branch("else")}); - let scopes = ["then", "else"].map(|key| (key, get_ref(d, key).and_then(|reference| super::steps_layer::action_set(reference)))); + let scopes = ["then", "else"].map(|key| (key, get_ref(d, key).and_then(super::steps_layer::action_set))); let shared = scopes .iter() .filter(|(key, _)| params[*key].is_string()) From 012342d87e6c3421ded1b58a2458273cb407914d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 10:10:57 -0800 Subject: [PATCH 129/384] Keep explicit imported calls within their declared scope --- apps/photocraft-cli/src/actions.rs | 3 +- apps/photocraft-cli/tests/it/cli.rs | 54 +++++++++++++++++++++++ crates/engine/src/cmd/atn_cmds/convert.rs | 41 ++++++++++++----- crates/engine/src/cmd/atn_cmds/files.rs | 3 +- crates/engine/tests/it/atn_call_scopes.rs | 25 ++++++++++- 5 files changed, 113 insertions(+), 13 deletions(-) diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 651aba5ba85..b25c1e2a73c 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -137,7 +137,8 @@ pub fn load_actions_file(path: &Path, name: Option<&str>) -> Result<(Steps, Vec< .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) .chain(imp.warnings.iter().cloned()) .collect(); - photocraft_engine::atn_cmds::normalize_import(&mut imp, std::iter::empty()).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; + photocraft_engine::atn_cmds::normalize_import(&mut imp, std::iter::empty(), false) + .map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?; let play = vec![("actions.play".to_string(), json!({"action": imp.actions[selected].name}))]; Ok((play, imp.actions, warnings)) } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index ddaa8d62529..ae291622a7e 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1938,3 +1938,57 @@ fn batch_refuses_ambiguous_nested_atn_calls_before_output() { assert!(String::from_utf8_lossy(&stderr).contains("ambiguous cross-set")); assert!(!output.exists()); } + +/// An explicit archive scope cannot fall back to a namesake in another physical set. +#[test] +fn batch_refuses_missing_or_ambiguous_explicit_atn_scopes_before_output() { + use photocraft_engine::{actions_cmds::Action, atn_cmds}; + use photocraft_psd::atn; + for failure in ["missing-target", "missing-set", "ambiguous-set"] { + for call in ["play", "shared", "then", "else"] { + let directory = tmp(&format!("nested-atn-scope-{failure}-{call}")); + let scope = if failure == "missing-set" { "Absent" } else { "A" }; + let file = directory.join("sets.atn"); + let finish = Action::new("Finish", vec![("layer.new.layer".into(), json!({}))]); + let other = Action::new("Other", Vec::new()); + let (id, params) = match call { + "play" => ("actions.play", json!({"action":"Finish","set":scope})), + "shared" => ("actions.conditional", json!({"if":"always","then":"Finish","else":null,"set":scope})), + "then" => ("actions.conditional", json!({"if":"always","then":"Finish","thenSet":scope,"else":"Finish","elseSet":"B"})), + _ => ("actions.conditional", json!({"if":"always","then":"Finish","thenSet":"B","else":"Finish","elseSet":scope})), + }; + let caller = Action::new("Caller", vec![(id.into(), params)]); + let group = |name: &str, actions: &[&Action]| { + let bytes = atn_cmds::export(name, actions, &[], false).unwrap().0; + atn::parse(&bytes).unwrap() + }; + let mut groups = vec![group("A", &[if failure == "ambiguous-set" { &finish } else { &other }])]; + if failure == "ambiguous-set" { + groups.push(group("A", &[&finish])); + } + groups.push(group("B", &[&finish, &caller])); + std::fs::write(&file, atn::write_sets(&groups).unwrap()).unwrap(); + let output = directory.join("out"); + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let args = [ + "batch".as_ref(), + "--in".as_ref(), + directory.as_os_str(), + "--out".as_ref(), + output.as_os_str(), + "--actions".as_ref(), + file.as_os_str(), + "--action".as_ref(), + "Caller".as_ref(), + ]; + assert_eq!(photocraft_cli::run(args, &mut stdout, &mut stderr), 1); + let error = match failure { + "missing-set" => "missing set", + "ambiguous-set" => "ambiguous action call set", + _ => "missing from set", + }; + assert!(String::from_utf8_lossy(&stderr).contains(error), "{call}: {}", String::from_utf8_lossy(&stderr)); + assert!(!output.exists()); + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 3b5b26d9302..87f832bf205 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -44,9 +44,14 @@ pub struct ImportedSet { } /// Give imported actions globally unique playback names while keeping calls bound to their physical set. -/// Explicit set references win over caller-local names; ambiguous cross-set calls are refused. +/// Explicit archive scopes never fall back to another set; external scopes stay unrewritten. /// The caller owns the uncommitted import, so a refusal never changes a live library. -pub fn normalize_import(imp: &mut Imported, reserved: impl IntoIterator) -> std::result::Result, String> { +/// `allow_external_calls` is false for a standalone CLI archive and true when importing into a session namespace. +pub fn normalize_import( + imp: &mut Imported, + reserved: impl IntoIterator, + allow_external_calls: bool, +) -> std::result::Result, String> { let mut set_for = vec![0usize; imp.actions.len()]; for (index, set) in imp.sets.iter().enumerate() { set_for.get_mut(set.actions.clone()).ok_or_else(|| "invalid imported set range".to_string())?.fill(index); @@ -87,14 +92,10 @@ pub fn normalize_import(imp: &mut Imported, reserved: impl IntoIterator( + sets: &[ImportedSet], + renamed: &'a [std::collections::HashMap], + scope: &str, + target: &str, + allow_external_calls: bool, +) -> std::result::Result, String> { + let mut matching = sets.iter().enumerate().filter(|(_, set)| set.name == scope); + let index = match matching.next() { + Some((index, _)) => index, + None if allow_external_calls => return Ok(None), + None => return Err(format!("action call references missing set `{scope}`")), + }; + if matching.next().is_some() { + return Err(format!("ambiguous action call set `{scope}`")); + } + renamed.get(index).and_then(|names| names.get(target)).map(Some).ok_or_else(|| format!("action call target `{target}` is missing from set `{scope}`")) +} + /// Retain an unsupported event's ID kind and descriptor for later export without executing it. fn photoshop_step(item: &AtnItem, label: String) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); diff --git a/crates/engine/src/cmd/atn_cmds/files.rs b/crates/engine/src/cmd/atn_cmds/files.rs index 00bccafcd2a..1288bf5d3ad 100644 --- a/crates/engine/src/cmd/atn_cmds/files.rs +++ b/crates/engine/src/cmd/atn_cmds/files.rs @@ -94,7 +94,8 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result { if old_rows.checked_add(new_rows).is_none_or(|count| count > super::atn::MAX_SETS) { return Err(bad(cmd, "too many action set rows")); } - let names = super::normalize_import(&mut imp, s.actions.list.iter().filter(keep).map(|action| action.name.clone())).map_err(|error| bad(cmd, error))?; + let names = + super::normalize_import(&mut imp, s.actions.list.iter().filter(keep).map(|action| action.name.clone()), true).map_err(|error| bad(cmd, error))?; let keys = imp.function_keys.clone(); let steps: usize = imp.actions.iter().map(|action| action.steps.len()).sum(); let added = imp.actions; diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 16e67b11c3c..71616ab8363 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -118,7 +118,7 @@ fn portable_conditional_branches_retain_independent_set_scopes() { let mut inherited = projected.clone(); inherited.actions[2].steps[0].1["thenSet"] = serde_json::Value::Null; inherited.actions[2].steps[0].1["set"] = json!("A"); - atn_cmds::normalize_import(&mut inherited, std::iter::empty()).unwrap(); + atn_cmds::normalize_import(&mut inherited, std::iter::empty(), false).unwrap(); assert_eq!(inherited.actions[2].steps[0].1["then"], "Finish", "null branch scope inherits the shared set, like the writer"); } let mut session = Session::new(); @@ -143,3 +143,26 @@ fn portable_conditional_shared_scope_keeps_the_existing_command_shape() { assert_eq!(imported.actions[0].steps[0].1, params); } } + +/// An out-of-archive scope still targets an existing library action after local names collide. +#[test] +fn imported_external_set_call_is_not_rewritten_to_an_archive_local_namesake() { + use photocraft_engine::atn_cmds; + let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action":"Finish","set":"External"}))]); + let local = finish("From B"); + let bytes = atn_cmds::export("B", &[&local, &caller], &[], false).unwrap().0; + let mut deferred = atn_cmds::import(&bytes).unwrap(); + atn_cmds::normalize_import(&mut deferred, ["Finish".to_string()], true).unwrap(); + assert_ne!(deferred.actions[0].name, "Finish"); + assert_eq!(deferred.actions[1].steps[0].1, json!({"action":"Finish","set":"External"}), "deferred reference remains intact"); + let mut session = Session::new(); + session.execute("file.new", json!({"width":4,"height":4})).unwrap(); + let mut external = finish("From External"); + external.set = Some("External".into()); + session.actions.list.push(external); + session.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes)})).unwrap(); + let result = session.execute("actions.play", json!({"action":"Caller"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + assert!(session.active().unwrap().doc.layers.iter().any(|layer| layer.name == "From External")); + assert!(!session.active().unwrap().doc.layers.iter().any(|layer| layer.name == "From B")); +} From 503c043a51c194bb119293de3bef39fc1222cb37 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 10:24:48 -0800 Subject: [PATCH 130/384] Keep scope regression fixtures faithful to explicit references --- apps/photocraft-cli/tests/it/cli.rs | 11 +++++++++- crates/engine/tests/it/atn_call_scopes.rs | 26 ++++++++++++++++------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index ae291622a7e..764bf1cf098 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1920,7 +1920,16 @@ fn batch_refuses_ambiguous_nested_atn_calls_before_output() { let bytes = atn_cmds::export(name, &[action], &[], false).unwrap().0; groups.push(atn::parse(&bytes).unwrap()); } - std::fs::write(&file, atn::write_sets(&groups).unwrap()).unwrap(); + // Portable export supplies the caller's set by default; remove it for this unscoped fixture. + let descriptor = groups.last_mut().unwrap().actions[0].items[0].descriptor.as_mut().unwrap(); + for (_, value) in &mut descriptor.items { + if let photocraft_psd::descriptor::Value::Reference(reference) = value { + reference.retain(|item| !matches!(item, photocraft_psd::descriptor::ReferenceItem::Name { class, .. } if class.class_id.is("ASet"))); + } + } + let bytes = atn::write_sets(&groups).unwrap(); + assert!(atn_cmds::import(&bytes).unwrap().actions[2].steps[0].1.get("set").is_none()); + std::fs::write(&file, bytes).unwrap(); let output = directory.join("out"); let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); let args = [ diff --git a/crates/engine/tests/it/atn_call_scopes.rs b/crates/engine/tests/it/atn_call_scopes.rs index 71616ab8363..602711f1a1c 100644 --- a/crates/engine/tests/it/atn_call_scopes.rs +++ b/crates/engine/tests/it/atn_call_scopes.rs @@ -35,12 +35,12 @@ fn finish(marker: &str) -> Action { Action::new("Finish", vec![("layer.new.layer".into(), json!({"name":marker}))]) } -/// Play and Conditional use the first named physical set, retaining legacy fallback rules. +/// Explicit scopes never fall through; unscoped calls retain caller-local resolution. #[test] fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming() { for conditional in [false, true] { for local in [false, true] { - for scope in [Some("B"), None, Some("Missing"), Some("Empty")] { + for scope in [Some("A"), Some("B"), None, Some("Missing"), Some("Empty")] { let mut params = if conditional { json!({"if":"always","then":"Finish"}) } else { json!({"action":"Finish"}) }; if let Some(scope) = scope { params["set"] = json!(scope); @@ -68,11 +68,16 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( session.execute("file.new", json!({"width":8,"height":8})).unwrap(); let before = session.actions.clone(); let result = session.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(&bytes),"replace":false})); - let expected = if scope == Some("B") { - Some("From B first") - } else if scope == Some("Empty") { - None - } else if local { + if scope == Some("Missing") { + result.unwrap(); + let caller = session.actions.list.iter().find(|action| action.name == "Caller").unwrap(); + assert_eq!(caller.steps[0].1[if conditional { "then" } else { "action" }], "Finish"); + assert_eq!(caller.steps[0].1["set"], "Missing", "deferred external reference stays unrewritten"); + continue; + } + let expected = if scope == Some("A") { + Some("From A") + } else if scope.is_none() && local { Some("From C") } else { None @@ -88,7 +93,12 @@ fn imported_named_set_calls_keep_their_target_through_duplicate_action_renaming( assert_eq!(session.active().unwrap().doc.layers.last().unwrap().name, expected); } else { let error = result.unwrap_err().to_string(); - assert!(error.contains("ambiguous cross-set"), "{error}"); + let expected = match scope { + Some("B") => "ambiguous action call set", + Some("Empty") => "missing from set", + _ => "ambiguous cross-set", + }; + assert!(error.contains(expected), "{error}"); assert_eq!(session.actions, before, "refusal retains the original definitions"); } } From cf1e0afa0df5616d8d1abf2a96d9eade211a3591 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 11:19:41 -0800 Subject: [PATCH 131/384] Configure paused fixtures before playback and refresh combined scorecard --- .../src/panels/actions/new_layout_tests.rs | 2 +- crates/ui-egui/src/panels/actions/tests.rs | 24 +++++++++++++------ docs/scorecard.md | 4 ++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/actions/new_layout_tests.rs b/crates/ui-egui/src/panels/actions/new_layout_tests.rs index 1a1231110df..2ee9abe9b1d 100644 --- a/crates/ui-egui/src/panels/actions/new_layout_tests.rs +++ b/crates/ui-egui/src/panels/actions/new_layout_tests.rs @@ -53,7 +53,7 @@ fn streamed_history_keeps_the_root_boundary_across_quanta_and_nested_dialogs() { assert!(!app.ui.status_error, "{}", app.ui.status); let state = app.session.active().unwrap(); assert!(state.doc.layers.iter().any(|layer| layer.name == "Tail")); - assert_eq!(state.history.past_len(), past + if single { 1 } else { 2 }, "nested={nested}, dialog={dialog}"); + assert_eq!(state.history.past_len(), past + if single { 1 } else { 2 + usize::from(dialog) }, "nested={nested}, dialog={dialog}"); if single { assert_eq!(state.history.entries().last().map(String::as_str), Some("Action: Root")); app.run("edit.undo", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 2b95ab3293b..827ee8de34d 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -104,6 +104,15 @@ fn single_history_dialog_refuses_intervening_edit_or_document_switch() { /// Prepare a real paused spelling step with a known document and history boundary, under either grouping policy. fn paused_history_spelling(single: bool, gate: Option) -> (PhotocraftApp, std::sync::Arc, usize) { + paused_history_spelling_with(single, gate, |_| {}) +} + +/// Configure the complete definition before its immutable playback snapshot is paused. +fn paused_history_spelling_with( + single: bool, + gate: Option, + configure: impl FnOnce(&mut Vec), +) -> (PhotocraftApp, std::sync::Arc, usize) { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width":300,"height":100})).unwrap(); app.run("type.create", json!({"x":10,"y":50,"text":"Teh brwon"})).unwrap(); @@ -120,6 +129,7 @@ fn paused_history_spelling(single: bool, gate: Option) -> (PhotocraftApp, ); action.dialogs.insert(1); app.session.actions.list.push(action); + configure(&mut app.session.actions.list); app.session.authorize = gate; let paused = play(&mut app, &json!({"action":"Spell"})).unwrap(); assert_eq!(paused["paused"]["dialog"], crate::type_panels_ui::SPELLING, "{paused}"); @@ -2400,11 +2410,12 @@ fn a_changed_live_gate_cannot_be_replaced_for_the_remaining_action() { } for service in [false, true] { for nested in [false, true] { - let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur)); - if nested { - app.session.actions.list.push(Action::new("Nested tail", vec![("layer.renameLayer".into(), json!({"name":"Forbidden tail"}))])); - app.session.actions.list[0].steps[2] = ("actions.play".into(), json!({"action":"Nested tail"})); - } + let (mut app, before, past) = paused_history_spelling_with(true, Some(deny_wide_blur), |list| { + if nested { + list.push(Action::new("Nested tail", vec![("layer.renameLayer".into(), json!({"name":"Forbidden tail"}))])); + list[0].steps[2] = ("actions.play".into(), json!({"action":"Nested tail"})); + } + }); spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); let corrected = app.session.active().unwrap().doc.clone(); if service { @@ -2509,8 +2520,7 @@ fn configured_gates_require_one_composed_policy_for_automated_resume() { #[test] /// A policy change after the final spelling step must not discard completed edits when no tail remains to dispatch. fn a_changed_gate_after_the_last_spelling_step_keeps_completed_edits() { - let (mut app, before, past) = paused_history_spelling(true, Some(deny_wide_blur)); - app.session.actions.list[0].steps.truncate(2); + let (mut app, before, past) = paused_history_spelling_with(true, Some(deny_wide_blur), |list| list[0].steps.truncate(2)); spelling_change(&mut app, json!({"action":"change","start":0,"end":3,"word":"Teh","replace":"The"})).unwrap(); let corrected = app.session.active().unwrap().doc.clone(); app.session.authorize = Some(|_, _| Ok(())); diff --git a/docs/scorecard.md b/docs/scorecard.md index 713d5a3dc8d..c63d5968d20 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 187 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 188 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 3 | 3 | 640 of 640 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -203,7 +203,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 187** (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: 10 of 188** (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`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From 6c1f920bff8a257f74713460c4aecde0272143ef Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 11:30:11 -0800 Subject: [PATCH 132/384] Keep playback history transfer within strict lint limits --- crates/ui-egui/src/panels/actions.rs | 36 ++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index bca5994b6e1..a6348dae001 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -540,11 +540,24 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ } } } - if failed.is_none() - && (paused.is_some() || yielded) + if failed.is_none() { + suspend_frame_history(app, paused.as_ref(), yielded); + } + actions_cmds::finish_playback(&mut app.session, None, &action, frame.from); + Ok(match (failed, paused) { + (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), + (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), + (None, None) if yielded => json!({"action": action.name, "ran": ran, "yielded":true}), + (None, None) => json!({"action": action.name, "ran": ran}), + }) +} + +/// Transfer the current history to the paused dialog or yielded playback before unwinding. +fn suspend_frame_history(app: &mut PhotocraftApp, paused: Option<&Value>, yielded: bool) { + if (paused.is_some() || yielded) && let Some(history) = actions_cmds::suspend_history(&mut app.session) { - if let Some(dialog) = paused.as_ref().and_then(waiting_dialog) { + if let Some(dialog) = paused.and_then(waiting_dialog) { if let Some(token) = paused_token(app, dialog) && let Some(entry) = app.paused_playbacks.paused.get_mut(&token) { @@ -554,13 +567,6 @@ fn run_frame(app: &mut PhotocraftApp, frame: Frame, used: Option, preserve_ app.actions_runtime.history = Some(history); } } - actions_cmds::finish_playback(&mut app.session, None, &action, frame.from); - Ok(match (failed, paused) { - (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), - (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), - (None, None) if yielded => json!({"action": action.name, "ran": ran, "yielded":true}), - (None, None) => json!({"action": action.name, "ran": ran}), - }) } /// Execute the actual menu command, not a synthetic keystroke. Fit is deferred @@ -1263,11 +1269,11 @@ fn resume_scope(app: &mut PhotocraftApp, paused: &mut PausedPlayback) -> Result< if let Some(record) = &paused.record { record.validate(&app.session).map_err(|error| error.to_string())?; } - if let Some(history) = paused.history.take() { - if let Err(error) = history.resume(&mut app.session) { - paused.history = Some(history); - return Err(error.to_string()); - } + if let Some(history) = paused.history.take() + && let Err(error) = history.resume(&mut app.session) + { + paused.history = Some(history); + return Err(error.to_string()); } if let Some(job) = app.session.active().and_then(|doc| app.session.job_on(doc.doc.id)) { app.session.wait_job(job.id).map_err(|error| error.to_string())?; From c0e7efa4bc5cdb9b47e3344500e8717808dc1810 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 11:56:02 -0800 Subject: [PATCH 133/384] Assert preserved set references in ATN round-trip tests --- crates/engine/src/cmd/atn_cmds/tests.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index e02a8b10edd..f31feeab89b 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -399,7 +399,8 @@ fn save_round_trips_through_photoshop_events() { let mut s = Session::new(); let bytes = atn::write(&sample()).unwrap(); s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); - let first = s.actions.list.clone(); + let mut first = s.actions.list.clone(); + assert_eq!(first[0].steps[7], ("actions.play".into(), json!({"action": "Finish"}))); let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap(); assert_eq!(r["actions"], json!(["Soften", "Finish"])); assert_eq!(r["skipped"], json!([])); @@ -416,7 +417,9 @@ fn save_round_trips_through_photoshop_events() { // Play names the set too, as Photoshop does. assert_eq!(get_ref(file.actions[0].items[7].descriptor.as_ref().unwrap(), "null").unwrap().len(), 2); - // Loading what was saved gives the same actions. + // Export supplies the containing set for an unscoped Play; reload preserves that + // explicit scope and every other logical action field. + first[0].steps[7].1["set"] = json!("Test Set"); let mut t = Session::new(); t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); assert_eq!(t.actions.list, first); @@ -1805,7 +1808,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { assert_eq!(more_steps[2].1, json!({"target": {"index": 0}})); assert_eq!(more_steps[4].1, json!({"foreground": "#000000"})); assert!(more.unsupported.is_empty()); - assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); + assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "thenSet": "FS", "else": "FS8-No-Layers"})); assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})); assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); @@ -2867,7 +2870,7 @@ fn partial_play_action_is_not_exported_as_whole_action_call() { let (bytes, skipped) = export("Set", &[&whole], &[], false).unwrap(); assert!(skipped.is_empty()); let imported = import(&bytes).unwrap(); - assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child"}))]); + assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child", "set": "Set"}))]); } } From 3505982ba8c668c22a5b40443322427f90bdab0a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 12:00:26 -0800 Subject: [PATCH 134/384] Keep late capture ownership and validate conditional scope types --- .../engine/src/cmd/actions_cmds/playback.rs | 5 ++ crates/engine/src/cmd/actions_edit_cmds.rs | 28 +++++++- crates/ui-egui/src/panels/actions.rs | 16 ++++- .../src/panels/actions/owned_preview_tests.rs | 72 +++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds/playback.rs b/crates/engine/src/cmd/actions_cmds/playback.rs index 3cb81edc373..d305188a55d 100644 --- a/crates/engine/src/cmd/actions_cmds/playback.rs +++ b/crates/engine/src/cmd/actions_cmds/playback.rs @@ -106,6 +106,11 @@ impl Owner { Ok((Arc::new(action), from, owner)) } + /// Suspend a recorder started while an unowned library dialog waited, using the same take-bound owner. + pub fn continuation(s: &mut Session, action: &Action, from: usize) -> Result> { + suspend(s, None, Some((action.name.clone(), from)), false) + } + /// Between UI ticks, reject unowned commands before their pre-command effects or jobs. pub fn yielding(&self, s: &mut Session, yielding: bool) -> Result<()> { self.validate(s)?; diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index 120d7d232cc..ef6918202f2 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -394,8 +394,8 @@ fn conditional(s: &mut Session, p: &Value) -> Result { if let Some(name) = played { let mut call = json!({"action": name}); let key = if holds { "thenSet" } else { "elseSet" }; - if let Some(scope) = p.get(key).and_then(Value::as_str).or_else(|| p.get("set").and_then(Value::as_str)) { - call["set"] = json!(scope); + if let Some(scope) = p.get(key).filter(|scope| !scope.is_null()).or_else(|| p.get("set").filter(|scope| !scope.is_null())) { + call["set"] = scope.clone(); } let r = s.execute("actions.play", call)?; if let Some(f) = r.get("failed") { @@ -534,6 +534,30 @@ mod tests { assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); } + /// A malformed selected scope must not become an unscoped call or inherit a valid shared scope. + #[test] + fn conditional_rejects_malformed_selected_scopes_before_playback() { + for (condition, branch) in [("always", "then"), ("hasSelection", "else")] { + for field in ["set".to_string(), format!("{branch}Set")] { + let mut s = session_with(&[]); + s.actions.list[0].set = Some("Intended".into()); + s.actions.list[0].steps = vec![("layer.new.layer".into(), json!({"name":"Wrong fallback"}))]; + let before = s.active().unwrap().doc.clone(); + let mut params = json!({"if":condition,"then":"A","else":"A","set":"Intended"}); + params[&field] = json!(5); + let error = s.execute("actions.conditional", params).unwrap_err().to_string(); + assert!(error.contains("set"), "{field}: {error}"); + assert_eq!(s.active().unwrap().doc.as_ref(), before.as_ref(), "{field} never plays the unscoped A"); + } + } + let mut s = session_with(&[]); + s.actions.list[0].set = Some("Intended".into()); + s.actions.list[0].steps = vec![("layer.new.layer".into(), json!({"name":"Intended"}))]; + let played = s.execute("actions.conditional", json!({"if":"always","then":"A","thenSet":null,"set":"Intended"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().doc.layers.last().unwrap().name, "Intended"); + } + #[test] fn conditional_tests_the_document_and_plays_a_branch() { let mut s = session_with(&[]); diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index a6348dae001..25b5a92a822 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -1134,7 +1134,9 @@ pub(crate) fn run_step_dialog(app: &mut PhotocraftApp, fields: &serde_json::Map< /// Bind the dialog's own edit to its suspended action before it runs or starts a worker. pub(crate) fn prepare_history(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result<(), String> { let Some(token) = fields.get(RESUME).and_then(Value::as_u64) else { return Ok(()) }; - let history = app.paused_playbacks.paused.get_mut(&token).ok_or("this dialog doesn't belong to a paused action")?.history.take(); + let paused = app.paused_playbacks.paused.get_mut(&token).ok_or("this dialog doesn't belong to a paused action")?; + suspend_later_capture(&mut app.session, paused)?; + let history = paused.history.take(); if let Some(history) = history && let Err(e) = history.resume(&mut app.session) { @@ -1263,9 +1265,21 @@ pub(crate) fn resume_spelling(app: &mut PhotocraftApp, token: u64) -> Result<(), Err(error.into()) } +/// A plain library pause may gain a recorder while waiting. Hold it before the confirmed edit +/// or resumed tail, and let the existing owner restore it on completion, cancellation or failure. +fn suspend_later_capture(session: &mut photocraft_engine::Session, paused: &mut PausedPlayback) -> Result<(), String> { + if paused.record.is_none() && session.actions.recording_active() { + paused.frame.validate(session)?; + paused.record = actions_cmds::playback::Owner::continuation(session, &paused.frame.action, paused.frame.from.saturating_sub(1)) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + /// Restore the bound history and completed worker before admitting the remaining immutable tail. fn resume_scope(app: &mut PhotocraftApp, paused: &mut PausedPlayback) -> Result, String> { paused.frame.validate(&app.session)?; + suspend_later_capture(&mut app.session, paused)?; if let Some(record) = &paused.record { record.validate(&app.session).map_err(|error| error.to_string())?; } diff --git a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs index ed637506435..25e7b6ba0da 100644 --- a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs +++ b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs @@ -358,3 +358,75 @@ fn a_resumed_preview_keeps_its_tree_step_budget() { assert_eq!(app.session.actions.playback_steps, actions_cmds::MAX_PLAYBACK_STEPS); assert!(app.session.actions.held_playback.is_none()); } + +/// A recorder started during an ordinary library pause survives confirmation, a second pause, +/// cancellation and command failure, and captures later manual work once rather than playback internals. +#[test] +fn a_recorder_started_during_a_library_dialog_survives_its_continuation() { + for exit in ["complete", "cancel first", "cancel second", "command error"] { + let mut app = app(); + let mut child = Action::new( + "Child", + vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius":1})), + ("filter.blur.gaussianBlur".into(), json!({"radius":1})), + ("layer.new.layer".into(), json!({"name":"Suffix"})), + ], + ); + child.dialogs.extend([0, 1]); + app.session.actions.list.push(child); + app.run("actions.play", json!({"action":"Child"})).unwrap(); + let first = dialog(&app); + app.run("actions.record", json!({"name":"Later"})).unwrap(); + let take = app.session.actions.take; + assert!(app.session.actions.recording_active()); + if exit == "cancel first" { + crate::dialogs::cancel(&mut app, first).unwrap(); + } else { + crate::dialogs::confirm(&mut app, first).unwrap(); + assert!(!app.session.actions.recording_active(), "capture remains held across the second pause: {exit}"); + assert!(app.session.actions.held_playback.is_some()); + assert_eq!(app.session.actions.take, take, "the second pause retains the same take"); + let second = dialog(&app); + assert_ne!(first, second); + match exit { + "cancel second" => crate::dialogs::cancel(&mut app, second).unwrap(), + "command error" => { + app.ui.dialog_mut(second).unwrap().fields.insert("radius".into(), json!("invalid")); + assert!(crate::dialogs::confirm(&mut app, second).is_err()); + } + _ => { + crate::dialogs::confirm(&mut app, second).unwrap(); + } + } + } + assert!(app.session.actions.recording_active(), "{exit}"); + assert_eq!(app.session.actions.take, take, "{exit} restores the original later take"); + assert!(app.session.actions.held_playback.is_none()); + assert!(app.paused_playbacks.paused.is_empty()); + app.run("layer.new.layer", json!({"name":"After"})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + let recorded = &app.session.actions.list.iter().find(|action| action.name == "Later").unwrap().steps; + let manual = ("layer.new.layer".into(), json!({"name":"After"})); + let expected = if exit == "cancel first" { vec![manual] } else { vec![("actions.play".into(), json!({"action":"Child"})), manual] }; + assert_eq!(recorded, &expected, "{exit}"); + assert_eq!(has_layer(&app, "Suffix"), exit == "complete"); + } +} + +/// Even an empty remaining tail must release the capture acquired for the confirmed command. +#[test] +fn a_late_recorder_survives_a_library_dialog_with_no_tail() { + let mut app = app(); + let mut child = Action::new("Only dialog", vec![("filter.blur.gaussianBlur".into(), json!({"radius":1}))]); + child.dialogs.insert(0); + app.session.actions.list.push(child); + app.run("actions.play", json!({"action":"Only dialog"})).unwrap(); + let id = dialog(&app); + app.run("actions.record", json!({"name":"Later"})).unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.actions.recording_active()); + assert!(app.session.actions.held_playback.is_none()); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[1].steps, vec![("actions.play".into(), json!({"action":"Only dialog"}))]); +} From f9bc661def01f32ff82b769cdbab4e66a505084c Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 12:25:20 -0800 Subject: [PATCH 135/384] Preserve native ATN options through the durable test round-trip --- crates/engine/tests/it/atn_classic_gradient.rs | 6 ++---- .../engine/tests/it/atn_content_aware_fill.rs | 11 ++++++++--- crates/engine/tests/it/atn_type_path.rs | 4 ++-- crates/engine/tests/support/atn_export.rs | 17 +++++++++++++---- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/engine/tests/it/atn_classic_gradient.rs b/crates/engine/tests/it/atn_classic_gradient.rs index 709b7a7d90b..254c47d2fc6 100644 --- a/crates/engine/tests/it/atn_classic_gradient.rs +++ b/crates/engine/tests/it/atn_classic_gradient.rs @@ -195,7 +195,7 @@ fn malformed_unknown_and_unrepresentable_descriptors_stay_whole() { } #[test] -fn unsupported_native_targets_and_damaged_origins_are_refused() { +fn unsupported_native_targets_and_damaged_origins_use_exact_native_fallback() { let loaded = imported(fixture("Lnr ", "Gcls", false)); for (key, value) in [ ("target", json!("mask")), @@ -211,9 +211,7 @@ fn unsupported_native_targets_and_damaged_origins_are_refused() { ] { let mut action = loaded.actions[0].clone(); action.steps[0].1[key] = value; - assert!(atn_cmds::export("S", &[&action], &[], false).is_err(), "{key}"); - let (_, skipped) = atn_cmds::export("S", &[&action], &[], true).unwrap(); - assert_eq!(skipped.len(), 1, "{key}"); + crate::atn_export::fallback("S", &action); } } diff --git a/crates/engine/tests/it/atn_content_aware_fill.rs b/crates/engine/tests/it/atn_content_aware_fill.rs index bb175549b4c..b7f15e2ba0d 100644 --- a/crates/engine/tests/it/atn_content_aware_fill.rs +++ b/crates/engine/tests/it/atn_content_aware_fill.rs @@ -49,9 +49,10 @@ fn load(d: Descriptor) -> Action { loaded.actions.into_iter().next().unwrap() } +/// Inspect only the explicit Photoshop projection; unsupported native options are omitted here. fn exported(p: Value) -> Option { let a = Action::new("A", vec![(CMD.into(), p)]); - let bytes = export("S", &[&a], &[], false).ok()?.0; + let bytes = export("S", &[&a], &[], true).ok()?.0; atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next() } @@ -84,7 +85,7 @@ fn caf_settings_roundtrip_seven_fields_and_boolean_choices() { } } -/// Unedited source identity, order and flags survive; damaged preservation metadata is refused. +/// Unedited wire bytes survive; damaged preservation metadata uses exact native fallback. #[test] fn caf_source_order_and_flags_survive_without_reviving_stale_settings() { let mut d = descriptor(); @@ -103,11 +104,13 @@ fn caf_source_order_and_flags_survive_without_reviving_stale_settings() { assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], source); let mut damaged = a.steps[0].1.clone(); damaged["_atnCafDescriptor"] = json!("not base64"); + crate::atn_export::fallback("S", &Action::new("A", vec![(CMD.into(), damaged.clone())])); assert!(exported(damaged).is_none()); let invalid = descriptor().with("extra", D::Boolean(true)); let mut damaged = a.steps[0].1.clone(); damaged["_atnCafDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes())); - assert!(exported(damaged).is_none(), "valid base64 cannot hide an unrepresented source field"); + crate::atn_export::fallback("S", &Action::new("A", vec![(CMD.into(), damaged.clone())])); + assert!(exported(damaged).is_none(), "valid base64 cannot hide an unrepresented source field in Photoshop output"); } /// Representable edits export their new values canonically, retaining step flags without skips. @@ -247,9 +250,11 @@ fn caf_native_exports_preserve_effective_defaults_and_refuse_unrepresented_optio ] { let mut p = params(); p[key] = value; + crate::atn_export::fallback("S", &Action::new("A", vec![(CMD.into(), p.clone())])); assert!(exported(p.clone()).is_none(), "{p}"); } for p in [json!({}), json!({"output":"new"}), json!([])] { + crate::atn_export::fallback("S", &Action::new("A", vec![(CMD.into(), p.clone())])); assert!(exported(p.clone()).is_none(), "unrepresented native default: {p}"); } } diff --git a/crates/engine/tests/it/atn_type_path.rs b/crates/engine/tests/it/atn_type_path.rs index f5b565b8493..a64ffd2a871 100644 --- a/crates/engine/tests/it/atn_type_path.rs +++ b/crates/engine/tests/it/atn_type_path.rs @@ -173,10 +173,10 @@ fn non_text_and_no_document_playback_fail_without_edits() { /// Explicit native targets and corrupt wire metadata cannot export as the implicit measured form. #[test] -fn native_export_refuses_explicit_targets_and_damaged_metadata() { +fn native_export_preserves_explicit_targets_and_damaged_metadata_without_approximation() { for params in [json!({"layer":1}), json!({"layers":[1]}), json!({(SOURCE):"bad base64"}), json!({(SOURCE):Value::Null})] { let action = Action::new("Path", vec![(COMMAND.into(), params)]); - assert!(export("Paths", &[&action], &[], false).is_err()); + crate::atn_export::fallback("Paths", &action); } let action = Action::new("Path", vec![(COMMAND.into(), json!({}))]); assert_eq!(saved(&action).descriptor.unwrap(), descriptor()); diff --git a/crates/engine/tests/support/atn_export.rs b/crates/engine/tests/support/atn_export.rs index 55c834444ae..844920be645 100644 --- a/crates/engine/tests/support/atn_export.rs +++ b/crates/engine/tests/support/atn_export.rs @@ -1,5 +1,10 @@ //! Assertions for the shared ATN library's native fallback and opt-in Photoshop omission. -use photocraft_engine::{actions_cmds::Action, atn_cmds}; +use photocraft_engine::{ + Session, + actions_cmds::Action, + atn_cmds, + preset_store::{self, ACTIONS_FILE, MemBackend, PresetBackend}, +}; use photocraft_psd::{ atn::{self, AtnItem}, descriptor::Value as D, @@ -8,9 +13,13 @@ use photocraft_psd::{ pub fn preserved(set: &str, action: &Action) -> Vec { let (bytes, skipped) = atn_cmds::export(set, &[action], &[], false).unwrap(); assert!(skipped.is_empty()); - let loaded = atn_cmds::import(&bytes).unwrap(); - assert!(loaded.unsupported.is_empty()); - assert_eq!(loaded.actions[0].steps, action.steps); + // The durable library preserves every native ID and JSON value. Portable imports + // deliberately apply the stricter recorder policy, covered by native-admission tests. + let store = MemBackend::default(); + store.write(ACTIONS_FILE, &bytes).unwrap(); + let mut loaded = Session::new(); + assert!(loaded.attach_preset_store(preset_store::open(Box::new(store))).is_empty()); + assert_eq!(loaded.actions.list[0].steps, action.steps); atn::parse(&bytes).unwrap().actions[0].items.clone() } From 2a942f2e07c8ae23d6bf4323e4068b81a159bb38 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 12:36:29 -0800 Subject: [PATCH 136/384] Discard the cancel reply in the continuation regression --- crates/engine/tests/it/atn_classic_gradient.rs | 1 + crates/engine/tests/it/atn_content_aware_fill.rs | 1 + crates/engine/tests/support/atn_export.rs | 1 + crates/ui-egui/src/panels/actions/owned_preview_tests.rs | 4 +++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/it/atn_classic_gradient.rs b/crates/engine/tests/it/atn_classic_gradient.rs index 254c47d2fc6..41fe944db0c 100644 --- a/crates/engine/tests/it/atn_classic_gradient.rs +++ b/crates/engine/tests/it/atn_classic_gradient.rs @@ -194,6 +194,7 @@ fn malformed_unknown_and_unrepresentable_descriptors_stay_whole() { } } +/// Unsupported options cannot become an approximate Photoshop event: keep native JSON or explicitly omit the step. #[test] fn unsupported_native_targets_and_damaged_origins_use_exact_native_fallback() { let loaded = imported(fixture("Lnr ", "Gcls", false)); diff --git a/crates/engine/tests/it/atn_content_aware_fill.rs b/crates/engine/tests/it/atn_content_aware_fill.rs index b7f15e2ba0d..a82c68d0938 100644 --- a/crates/engine/tests/it/atn_content_aware_fill.rs +++ b/crates/engine/tests/it/atn_content_aware_fill.rs @@ -211,6 +211,7 @@ fn caf_unrepresented_descriptors_are_kept_and_never_partially_play() { } /// Known native defaults become explicit values; unsupported defaults/options cannot disappear. +/// Representable defaults use CAF events; every unrepresented option stays exact in native fallback and is omitted only on request. #[test] fn caf_native_exports_preserve_effective_defaults_and_refuse_unrepresented_options() { for p in [json!({"sampling":"rectangular","output":"new"}), json!({"sampling":"rectangular","output":"new","seed":1,"target":"pixels"}), params()] { diff --git a/crates/engine/tests/support/atn_export.rs b/crates/engine/tests/support/atn_export.rs index 844920be645..7de8305cd99 100644 --- a/crates/engine/tests/support/atn_export.rs +++ b/crates/engine/tests/support/atn_export.rs @@ -10,6 +10,7 @@ use photocraft_psd::{ descriptor::Value as D, }; +/// Load default export through the durable store, retaining every native ID and JSON parameter exactly. pub fn preserved(set: &str, action: &Action) -> Vec { let (bytes, skipped) = atn_cmds::export(set, &[action], &[], false).unwrap(); assert!(skipped.is_empty()); diff --git a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs index 25e7b6ba0da..d58725fb726 100644 --- a/crates/ui-egui/src/panels/actions/owned_preview_tests.rs +++ b/crates/ui-egui/src/panels/actions/owned_preview_tests.rs @@ -390,7 +390,9 @@ fn a_recorder_started_during_a_library_dialog_survives_its_continuation() { let second = dialog(&app); assert_ne!(first, second); match exit { - "cancel second" => crate::dialogs::cancel(&mut app, second).unwrap(), + "cancel second" => { + crate::dialogs::cancel(&mut app, second).unwrap(); + } "command error" => { app.ui.dialog_mut(second).unwrap().fields.insert("radius".into(), json!("invalid")); assert!(crate::dialogs::confirm(&mut app, second).is_err()); From e78534038852015cb683c9824914fa8b89cf8f12 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 13:19:04 -0800 Subject: [PATCH 137/384] Preserve Actions panel and WorkPath preparation before canonical integration --- crates/engine/src/cmd/actions_edit_cmds.rs | 24 +- .../src/cmd/actions_edit_cmds/defaults.rs | 18 ++ crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/events.rs | 1 + crates/engine/src/cmd/atn_cmds/path_tests.rs | 221 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/steps_make.rs | 2 + crates/engine/src/cmd/atn_cmds/steps_path.rs | 206 ++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 12 + crates/engine/tests/it/actions_panel_menu.rs | 17 +- crates/ui-egui/src/panels/actions/reset.rs | 6 +- crates/ui-egui/tests/it/actions_panel_menu.rs | 15 +- 11 files changed, 502 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/cmd/actions_edit_cmds/defaults.rs create mode 100644 crates/engine/src/cmd/atn_cmds/path_tests.rs create mode 100644 crates/engine/src/cmd/atn_cmds/steps_path.rs diff --git a/crates/engine/src/cmd/actions_edit_cmds.rs b/crates/engine/src/cmd/actions_edit_cmds.rs index d046f6918a5..7d42e16060e 100644 --- a/crates/engine/src/cmd/actions_edit_cmds.rs +++ b/crates/engine/src/cmd/actions_edit_cmds.rs @@ -18,11 +18,12 @@ mod record_again; pub use record_again::{RecordAgainTarget, target as record_again_target}; mod insert_path; pub use insert_path::selected_path as selected_recording_path; +mod defaults; -/// Temporary empty folder until Photoshop's intended default action contents are specified. -pub const DEFAULT_SET_PLACEHOLDER: &str = "Default Actions (placeholder)"; +/// PhotoCraft's original starter set, using existing document-local adjustments. +pub const DEFAULT_SET_NAME: &str = defaults::NAME; -/// Restore an explicit placeholder while keeping all admission checks before replacement. +/// Prepare real starter definitions and all admission checks before replacement. fn reset(s: &mut Session, p: &Value) -> Result { let cmd = "actions.reset"; not_playing(s, cmd)?; @@ -38,7 +39,18 @@ fn reset(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(cmd, "`mode` must be append or replace")), }; let names = if replace { Vec::new() } else { set_names(s) }; - let name = crate::atn_cmds::UniqueNames::new(names).claim(DEFAULT_SET_PLACEHOLDER); + let name = crate::atn_cmds::UniqueNames::new(names).claim(DEFAULT_SET_NAME); + let mut actions = defaults::actions(); + let existing = if replace { Vec::new() } else { s.actions.list.iter().map(|a| a.name.clone()).collect() }; + let mut action_names = crate::atn_cmds::UniqueNames::new(existing); + for action in &mut actions { + action.name = action_names.claim(&action.name); + action.set = Some(name.clone()); + if action.steps.iter().any(|(id, params)| !is_step_command(id) || !params.is_object()) { + return Err(bad(cmd, "invalid starter action definition")); + } + } + let count = actions.len(); let create = json!({"name":name}); if let Some(gate) = s.authorize { gate("actions.newSet", &create)?; @@ -48,7 +60,9 @@ fn reset(s: &mut Session, p: &Value) -> Result { } let cleared = if replace { clear_all(s, &json!({"confirm":true}))?["cleared"].clone() } else { json!(0) }; new_set(s, &create)?; - Ok(json!({"set":name,"placeholder":true,"actions":0,"cleared":cleared})) + s.actions.list.extend(actions); + s.actions.touch(); + Ok(json!({"set":name,"actions":count,"cleared":cleared})) } fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/cmd/actions_edit_cmds/defaults.rs b/crates/engine/src/cmd/actions_edit_cmds/defaults.rs new file mode 100644 index 00000000000..e74d83799df --- /dev/null +++ b/crates/engine/src/cmd/actions_edit_cmds/defaults.rs @@ -0,0 +1,18 @@ +//! PhotoCraft's original, document-local starter actions; no external assets or file effects. +use crate::actions_cmds::Action; +use serde_json::json; + +pub(super) const NAME: &str = "PhotoCraft Basics"; + +/// Small useful examples using existing native adjustments and ordinary Undo. +/// Folder occurrence identity belongs to the Actions library, not these names. +pub(super) fn actions() -> Vec { + [("Invert Colors", "image.adjustments.invert"), ("Desaturate", "image.adjustments.desaturate")] + .into_iter() + .map(|(name, command)| { + let mut action = Action::new(name, vec![(command.into(), json!({}))]); + action.set = Some(NAME.into()); + action + }) + .collect() +} diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 6326d9c4ce6..56724097201 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -45,6 +45,7 @@ mod steps_make; mod steps_stylize; mod steps_other; +mod steps_path; mod steps_render; mod steps_selection; mod tables; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index a29b4a19576..c9ef20c96ff 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -655,6 +655,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.new.layerFromBackground", "layer.layerStyle.blendingOptions", "layer.smartFilter.blendingOptions", + "path.set", ], from: set_from, to: set_to, diff --git a/crates/engine/src/cmd/atn_cmds/path_tests.rs b/crates/engine/src/cmd/atn_cmds/path_tests.rs new file mode 100644 index 00000000000..e277e269f33 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/path_tests.rs @@ -0,0 +1,221 @@ +use super::super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, UnicodeString, atn}; +use super::*; + +// Independent typed fixtures transcribed from10706#6617544257, not made by +// the converter under test. Fwd is out; Bwd is in. +fn wire_point(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y })) +} + +fn corner(x: f64, y: f64) -> D { + D::Descriptor(Descriptor::new("Pthp").with("Anch", wire_point(x, y))) +} + +fn fixture(operation: &str, joined: bool, reversed: bool) -> Descriptor { + let mut outer = vec![corner(50.0, 250.0), corner(50.0, 100.0), corner(350.0, 100.0), corner(350.0, 250.0)]; + if !joined { + outer[1] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(50.0, 100.0)) + .with("Fwd ", wire_point(50.0, 100.0)) + .with("Bwd ", wire_point(100.0, 20.0)) + .with("Smoo", D::Boolean(false)), + ); + outer[2] = D::Descriptor( + Descriptor::new("Pthp") + .with("Anch", wire_point(350.0, 100.0)) + .with("Fwd ", wire_point(300.0, 20.0)) + .with("Bwd ", wire_point(350.0, 100.0)) + .with("Smoo", D::Boolean(false)), + ); + } + let mut inner = vec![corner(150.0, 150.0), corner(250.0, 150.0), corner(250.0, 220.0), corner(150.0, 220.0)]; + if reversed { + inner.reverse(); + } + let sub = |points| D::Descriptor(Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(points))); + let component = |op, subs| { + D::Descriptor( + Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new(op) }) + .with("SbpL", D::List(subs)), + ) + }; + let components = if joined { + vec![component("Add ", vec![sub(outer), sub(inner)])] + } else { + vec![component("Add ", vec![sub(outer)]), component(operation, vec![sub(inner)])] + }; + Descriptor::new(if joined { "null" } else { "setd" }) + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(components)) +} + +fn params(d: &Descriptor) -> Value { + let Mapped::Steps(steps) = from(d) else { panic!("measured path must map") }; + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].0, "path.set"); + steps[0].1.clone() +} + +fn check_area(path: &Path) { + let pixels = photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::new(0, 0, 400, 300)); + for (x, y, expected) in [(100, 185, 1.0), (200, 185, 0.0), (10, 20, 0.0)] { + assert_eq!(pixels[y * 400 + x], expected, "sample {x},{y}"); + } +} + +#[test] +fn measured_curves_and_component_operations_round_trip_and_cut_holes() { + for (operation, expected) in [("Sbtr", PathOp::Subtract), ("xor", PathOp::Exclude)] { + let d = fixture(operation, false, false); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.fill_rule, FillRule::EvenOdd); + assert_eq!(path.subpaths[1].op, expected); + let knots = &path.subpaths[0].knots; + assert_eq!(knots[1].out_ctrl, Point::new(50.0, 100.0)); + assert_eq!(knots[1].in_ctrl, Point::new(100.0, 20.0)); + assert_eq!(knots[2].out_ctrl, Point::new(300.0, 20.0)); + assert_eq!(knots[2].in_ctrl, Point::new(350.0, 100.0)); + assert_eq!(to(&p), Some(d)); + check_area(&path); + } +} + +#[test] +fn one_component_has_even_odd_holes_with_either_inner_winding() { + for reversed in [false, true] { + let d = fixture("Add ", true, reversed); + let p = params(&d); + let path = crate::vector_cmds::parse_path(&p["path"]).unwrap(); + assert_eq!(path.components(), vec![0..2]); + assert_eq!(path.subpaths[1].op, PathOp::Join); + check_area(&path); + assert_eq!(to(&p), Some(d)); + } +} + +#[test] +fn whole_atn_and_native_insert_path_replay_keep_geometry() { + let original = fixture("Sbtr", false, false); + let file = AtnFile { + actions: vec![AtnAction { + items: vec![AtnItem { event: AtnEvent::name("set"), descriptor: Some(original.clone()), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let imported = super::super::import(&atn::write(&file).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + let (bytes, skipped) = super::super::export("S", &[&imported.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(original.clone())); + let expected = path_from(&original).unwrap(); + let mut s = crate::Session::new(); + s.execute("file.new", json!({"width":400,"height":300})).unwrap(); + s.execute("path.set", json!({"name":"Source","path":crate::vector_cmds::path_json(&expected)})).unwrap(); + s.actions.list.push(Action::new("Captured", Vec::new())); + let revision = s.active().unwrap().revision; + s.execute("actions.insertPath", json!({"action":0,"name":"Source"})).unwrap(); + assert_eq!(s.active().unwrap().revision, revision); + let (bytes, skipped) = super::super::export("S", &[&s.actions.list[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = super::super::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + s.actions.list = imported.actions; + s.execute("path.set", json!({"name":"Source","path":{"subpaths":[]}})).unwrap(); + let played = s.execute("actions.play", json!({"action":0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&expected)); + check_area(s.active().unwrap().doc.work_path.as_ref().unwrap()); +} + +#[test] +fn source_wire_identity_is_retained_only_for_unchanged_geometry() { + let mut d = fixture("xor", false, false); + d.name = UnicodeString::new_nul("Set"); + d.items.reverse(); + let mut p = params(&d); + assert_eq!(to(&p).unwrap().to_bytes(), d.to_bytes()); + p["path"]["subpaths"][0]["knots"][0]["anchor"][0] = json!(51.0); + assert!(to(&p).is_none()); +} + +#[test] +fn unknown_and_unrepresented_settings_are_not_silently_lost() { + let base = fixture("Sbtr", false, false); + for d in [base.clone().with("extra", D::Boolean(true)), base.clone().with("T ", D::List(Vec::new())), fixture("Intr", false, false)] { + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let p = params(&base); + let mut empty = p.clone(); + empty["path"]["subpaths"] = json!([]); + empty.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&empty).is_none()); + for key in ["inverted", "fillRule"] { + let mut changed = p.clone(); + changed["path"][key] = if key == "inverted" { json!(true) } else { json!("nonzero") }; + changed.as_object_mut().unwrap().remove(SOURCE); + assert!(to(&changed).is_none()); + } + let mut changed = p.clone(); + changed["path"]["unrepresented"] = json!(1); + assert!(to(&changed).is_none()); + let mut changed = p; + changed[SOURCE] = json!("not base64"); + assert!(to(&changed).is_none()); +} + +#[test] +fn malformed_coordinates_and_oversized_paths_stay_unsupported() { + fn list(value: &mut D) -> &mut Vec { + match value { + D::List(v) => v, + _ => panic!("fixture list"), + } + } + fn object(value: &mut D) -> &mut Descriptor { + match value { + D::Descriptor(v) => v, + _ => panic!("fixture object"), + } + } + for bad in [D::UnitFloat { unit: *b"#Prc", value: 50.0 }, D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }, D::Double(50.0)] { + let mut d = fixture("Sbtr", false, false); + let components = list(&mut d.items[1].1); + let subs = list(&mut object(&mut components[0]).items[1].1); + let points = list(&mut object(&mut subs[0]).items[1].1); + let anchor = object(&mut points[0]); + let coordinates = object(&mut anchor.items[0].1); + coordinates.items[0].1 = bad; + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); + } + let mut p = params(&fixture("Sbtr", false, false)); + p.as_object_mut().unwrap().remove(SOURCE); + let knot = p["path"]["subpaths"][0]["knots"][0].clone(); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot.clone(); MAX_PARTS - 4]); + assert!(to(&p).is_some(), "the existing 4096-knot ceiling remains representable"); + p["path"]["subpaths"][0]["knots"] = json!(vec![knot; MAX_PARTS + 1]); + assert!(to(&p).is_none()); +} + +#[test] +fn oversized_descriptor_names_are_rejected_before_retained_wire_serialization() { + // Each name meets the local limit, but their aggregate exceeds the carrier. + // Keep this typed input independent of the serializer under test. + let mut point = + Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }); + point.name = UnicodeString(vec![b'x' as u16; 4096]); + let mut knot = Descriptor::new("Pthp").with("Anch", D::Descriptor(point)); + knot.name = UnicodeString(vec![b'x' as u16; 4096]); + let sub = Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(vec![D::Descriptor(knot); 300])); + let component = Descriptor::new("PaCm") + .with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new("Add ") }) + .with("SbpL", D::List(vec![D::Descriptor(sub)])); + let d = Descriptor::new("setd") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])) + .with("T ", D::List(vec![D::Descriptor(component)])); + assert!(path_from(&d).is_none(), "recognition must reject before from() serializes the retained descriptor"); + assert!(matches!(from(&d), Mapped::Unsupported { harmless: false })); +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 07c71040a50..49cce4807f8 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -665,6 +665,7 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; match (ref_class(r).as_deref(), target(r)) { + (Some("Path"), Target::Property(p)) if p == "WrPt" => super::steps_path::from(d), (Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d), // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), @@ -765,6 +766,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); Some(match id { + "path.set" => return super::steps_path::to(p), "select.all" => sel(enm("Ordn", "Al ")), "select.deselect" => sel(enm("Ordn", "None")), "select.reselect" => sel(enm("Ordn", "Prvs")), diff --git a/crates/engine/src/cmd/atn_cmds/steps_path.rs b/crates/engine/src/cmd/atn_cmds/steps_path.rs new file mode 100644 index 00000000000..e7531069879 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_path.rs @@ -0,0 +1,206 @@ +//! Measured Insert Path: per-component operations, with even-odd subpaths in each component. +use super::descriptors::{PX, class, desc, enm, get_list, get_ref, jstr, px}; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Id, ReferenceItem, Value, json}; +use photocraft_doc::{FillRule, Knot, Path, PathOp, Subpath}; +use photocraft_geom::Point; + +pub(super) const SOURCE: &str = "_atnInsertPathDescriptor"; +// The native path validator admits at most4096 knots/subpaths. Larger wire +// records remain complete kept steps rather than secondary geometry allocations. +const MAX_PARTS: usize = 4096; +const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024; + +#[cfg(test)] +#[path = "path_tests.rs"] +mod tests; + +/// Bound the retained wire form before serializing it into a second allocation. +/// Names use UTF-16; 64 bytes per measured field conservatively covers its fixed +/// key/type/scalar or list header. Nested descriptors charge their own fields. +fn keys(d: &Descriptor, required: &[&str], optional: &[&str], budget: &mut usize) -> bool { + let size = d.name.0.len().checked_mul(2).and_then(|names| d.items.len().checked_mul(64)?.checked_add(names)?.checked_add(32)); + let Some(remaining) = size.and_then(|size| budget.checked_sub(size)) else { return false }; + *budget = remaining; + d.name.0.len() <= 4096 + && d.items.iter().all(|(k, _)| required.iter().chain(optional).any(|key| k.is(key))) + && required.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1) + && optional.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() <= 1) +} + +fn object<'a>(value: &'a D, class_id: &str) -> Option<&'a Descriptor> { + match value { + D::Descriptor(d) if d.class_id.is(class_id) => Some(d), + _ => None, + } +} + +fn point(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pnt ")?; + if !keys(d, &["Hrzn", "Vrtc"], &[], budget) { + return None; + } + let coordinate = |key| match d.get(key)? { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && value.abs() <= f64::from(f32::MAX) => Some(*value), + _ => None, + }; + Some(Point::new(coordinate("Hrzn")?, coordinate("Vrtc")?)) +} + +fn knot(value: &D, budget: &mut usize) -> Option { + let d = object(value, "Pthp")?; + if !keys(d, &["Anch"], &["Fwd ", "Bwd ", "Smoo"], budget) { + return None; + } + let anchor = point(d.get("Anch")?, budget)?; + match (d.get("Fwd "), d.get("Bwd "), d.get("Smoo")) { + (None, None, None) => Some(Knot::corner(anchor.x, anchor.y)), + (Some(out), Some(input), Some(D::Boolean(smooth))) => { + Some(Knot { anchor, out_ctrl: point(out, budget)?, in_ctrl: point(input, budget)?, smooth: *smooth }) + } + _ => None, + } +} + +fn path_from(d: &Descriptor) -> Option { + let mut budget = MAX_SOURCE_BYTES; + if !(d.class_id.is("null") || d.class_id.is("setd")) || !keys(d, &["null", "T "], &[], &mut budget) { + return None; + } + let Some([ReferenceItem::Property { class, key }]) = get_ref(d, "null") else { return None }; + if !class.class_id.is("Path") || class.name.0.len() > 4096 || !key.is("WrPt") { + return None; + } + budget = budget.checked_sub(class.name.0.len().checked_mul(2)?.checked_add(64)?)?; + let components = get_list(d, "T ")?; + if components.is_empty() || components.len() > MAX_PARTS { + return None; + } + let mut path = Path { fill_rule: FillRule::EvenOdd, ..Default::default() }; + let mut count = 0usize; + for value in components { + let component = object(value, "PaCm")?; + if !keys(component, &["shapeOperation", "SbpL"], &[], &mut budget) { + return None; + } + let op = match component.get("shapeOperation")? { + D::Enumerated { type_id, value } if type_id.is("shapeOperation") => { + if value.is("Add ") { + PathOp::Combine + } else if value.is("Sbtr") { + PathOp::Subtract + } else if value.is("xor") { + PathOp::Exclude + } else { + return None; + } + } + _ => return None, + }; + if path.subpaths.is_empty() && op != PathOp::Combine { + return None; + } + let subpaths = get_list(component, "SbpL")?; + if subpaths.is_empty() || path.subpaths.len().checked_add(subpaths.len())? > MAX_PARTS { + return None; + } + for (i, value) in subpaths.iter().enumerate() { + let sub = object(value, "Sbpl")?; + if !keys(sub, &["Clsp", "Pts "], &[], &mut budget) { + return None; + } + let D::Boolean(closed) = sub.get("Clsp")? else { return None }; + let points = get_list(sub, "Pts ")?; + count = count.checked_add(points.len())?; + if count > MAX_PARTS { + return None; + } + let knots = points.iter().map(|value| knot(value, &mut budget)).collect::>>()?; + path.subpaths.push(Subpath { closed: *closed, knots, op: if i == 0 { op } else { PathOp::Join } }); + } + } + photocraft_vector::flatten::validate_path(&path).ok()?; + Some(path) +} + +/// Decode only the measured Work Path form; unsupported data stays a complete kept step. +pub(super) fn from(d: &Descriptor) -> Mapped { + let Some(path) = path_from(d) else { return Mapped::Unsupported { harmless: false } }; + let raw = d.to_bytes(); + if raw.len() > MAX_SOURCE_BYTES { + return Mapped::Unsupported { harmless: false }; + } + step("path.set", json!({"name":"work", "path":crate::vector_cmds::path_json(&path), SOURCE:photocraft_paint::tile::b64_encode(&raw)})) +} + +fn point_to(p: Point) -> D { + D::Descriptor(desc("Pnt ").with("Hrzn", px(p.x)).with("Vrtc", px(p.y))) +} + +fn knot_to(k: &Knot) -> D { + let mut d = desc("Pthp").with("Anch", point_to(k.anchor)); + if k.in_ctrl != k.anchor || k.out_ctrl != k.anchor || k.smooth { + d = d.with("Fwd ", point_to(k.out_ctrl)).with("Bwd ", point_to(k.in_ctrl)).with("Smoo", D::Boolean(k.smooth)); + } + D::Descriptor(d) +} + +/// Export representable native geometry or an unchanged validated source descriptor. +/// Nonzero winding, inversion and unmeasured operations cannot silently become even-odd. +pub(super) fn to(p: &Value) -> Option { + if jstr(p, "name") != Some("work") || p.as_object()?.keys().any(|k| !matches!(k.as_str(), "name" | "path" | SOURCE)) { + return None; + } + let value = p.get("path")?; + let subpaths = value.get("subpaths")?.as_array()?; + if subpaths.is_empty() + || subpaths.len() > MAX_PARTS + || subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.get("knots")?.as_array()?.len()))? > MAX_PARTS + { + return None; + } + let path = crate::vector_cmds::parse_path(value).ok()?; + photocraft_vector::flatten::validate_path(&path).ok()?; + let mut canonical = crate::vector_cmds::path_json(&path); + let mut supplied = value.clone(); + super::convert::normalise(&mut canonical); + super::convert::normalise(&mut supplied); + if path.inverted || path.fill_rule != FillRule::EvenOdd || canonical != supplied { + return None; + } + if let Some(source) = p.get(SOURCE) { + let text = source.as_str()?; + if text.len() > MAX_SOURCE_BYTES.div_ceil(3) * 4 { + return None; + } + let raw = photocraft_paint::tile::b64_decode(text)?; + if raw.len() > MAX_SOURCE_BYTES { + return None; + } + let original = Descriptor::from_bytes(&raw).ok()?; + return (path_from(&original)? == path).then_some(original); + } + let mut components = Vec::new(); + for range in path.components() { + let first = path.subpaths.get(range.start)?; + let op = match first.op { + PathOp::Combine => "Add ", + PathOp::Subtract => "Sbtr", + PathOp::Exclude => "xor", + _ => return None, + }; + if components.is_empty() && first.op != PathOp::Combine { + return None; + } + let subs = path + .subpaths + .get(range)? + .iter() + .map(|s| D::Descriptor(desc("Sbpl").with("Clsp", D::Boolean(s.closed)).with("Pts ", D::List(s.knots.iter().map(knot_to).collect())))) + .collect(); + components.push(D::Descriptor(desc("PaCm").with("shapeOperation", enm("shapeOperation", op)).with("SbpL", D::List(subs)))); + } + Some( + desc("setd").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])).with("T ", D::List(components)), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index cbbbdad0153..5bf4b84604e 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -483,7 +483,15 @@ fn settings_at_the_value_the_event_plays_still_export() { /// Every exported command has a canonical import, including recorded full-stack navigation. #[test] fn every_mapped_command_exports_and_imports_back() { + let path = photocraft_doc::Path { + subpaths: vec![photocraft_doc::Subpath::polygon(&[(1.0, 2.0), (31.0, 2.0), (31.0, 24.0)])], + fill_rule: photocraft_doc::FillRule::EvenOdd, + inverted: false, + }; + let mut path = json!({"name":"work","path":crate::vector_cmds::path_json(&path)}); + normalise(&mut path); let cases: Vec<(&str, Value)> = vec![ + ("path.set", path), ("filter.render.clouds", json!({})), ("filter.render.differenceClouds", json!({})), ("filter.render.fibers", json!({"variance": 20, "strength": 7, "seed": -123})), @@ -842,6 +850,10 @@ fn every_mapped_command_exports_and_imports_back() { } assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); + if *id == "path.set" { + assert_eq!(super::steps_path::to(&back).as_ref(), item.descriptor.as_ref()); + back.as_object_mut().unwrap().remove(super::steps_path::SOURCE); + } normalise(&mut back); if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); diff --git a/crates/engine/tests/it/actions_panel_menu.rs b/crates/engine/tests/it/actions_panel_menu.rs index 6fca4270486..68b18e669cf 100644 --- a/crates/engine/tests/it/actions_panel_menu.rs +++ b/crates/engine/tests/it/actions_panel_menu.rs @@ -166,9 +166,9 @@ fn deny_new_set(id: &str, _: &Value) -> photocraft_engine::Result<()> { if id == "actions.newSet" { Err(EngineError::Other("new set denied".into())) } else { Ok(()) } } -/// The placeholder is explicit, both modes work, and all refusals precede destructive mutation. +/// Real starter definitions are installed only after consent and all destructive admission checks. #[test] -fn reset_placeholder_appends_or_replaces_only_after_valid_consent() { +fn reset_defaults_append_or_replace_only_after_valid_consent() { let mut s = session(); let original = s.actions.clone(); for p in [json!({}), json!({"mode":"replace"}), json!({"mode":"wrong","confirm":true}), json!({"mode":"append","confirm":false})] { @@ -180,9 +180,14 @@ fn reset_placeholder_appends_or_replaces_only_after_valid_consent() { assert_eq!(s.actions, original); s.authorize = None; let result = s.execute("actions.reset", json!({"mode":"append","confirm":true})).unwrap(); - assert_eq!(result["placeholder"], true); - assert_eq!(s.actions.list, original.list); + assert_eq!(result["actions"], 2); + assert_eq!(&s.actions.list[..original.list.len()], original.list.as_slice()); + assert_eq!(s.actions.list.len(), original.list.len() + 2); s.execute("actions.reset", json!({"mode":"replace","confirm":true})).unwrap(); - assert!(s.actions.list.is_empty()); - assert_eq!(s.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); + assert_eq!(s.actions.list.len(), 2); + assert_eq!(s.actions.sets, [actions_edit_cmds::DEFAULT_SET_NAME]); + for (action, command) in s.actions.list.iter().zip(["image.adjustments.invert", "image.adjustments.desaturate"]) { + assert_eq!(action.set.as_deref(), Some(actions_edit_cmds::DEFAULT_SET_NAME)); + assert_eq!(action.steps, [(command.into(), json!({}))]); + } } diff --git a/crates/ui-egui/src/panels/actions/reset.rs b/crates/ui-egui/src/panels/actions/reset.rs index 1b3148eecd5..8ecebfce88b 100644 --- a/crates/ui-egui/src/panels/actions/reset.rs +++ b/crates/ui-egui/src/panels/actions/reset.rs @@ -1,4 +1,4 @@ -//! Reset offers explicit append/replace consent while default contents remain a placeholder. +//! Reset offers explicit append/replace consent for PhotoCraft's original starter actions. use crate::{PhotocraftApp, state::DialogKind}; use serde_json::{Map, Value, json}; const COMMAND: &str = "actions.reset"; @@ -49,10 +49,10 @@ pub(super) fn open(app: &mut PhotocraftApp) -> Result { Ok(json!({"dialog":dialog})) } -/// Clearly label the temporary content and offer append, replace or the standard Cancel. +/// Identify the starter actions and offer append, replace or the standard Cancel. pub(crate) fn body(app: &PhotocraftApp, ui: &mut egui::Ui, dialog: u64, fields: &mut Map) { let held = app.held_actions_reset.as_ref().filter(|held| held.dialog == dialog); - ui.label(tl!("Default Actions contents are not available yet. This restores an empty placeholder set.")); + ui.label(tl!("Restore PhotoCraft Basics: Invert Colors and Desaturate.")); let mut mode = fields.get("mode").and_then(Value::as_str).unwrap_or("append").to_string(); ui.add_enabled_ui(held.is_some_and(|held| held.append), |ui| ui.radio_value(&mut mode, "append".into(), tl!("Append"))); ui.add_enabled_ui(held.is_some_and(|held| held.replace), |ui| ui.radio_value(&mut mode, "replace".into(), tl!("Replace"))); diff --git a/crates/ui-egui/tests/it/actions_panel_menu.rs b/crates/ui-egui/tests/it/actions_panel_menu.rs index d3d3479fcf6..a339394f8ba 100644 --- a/crates/ui-egui/tests/it/actions_panel_menu.rs +++ b/crates/ui-egui/tests/it/actions_panel_menu.rs @@ -134,7 +134,7 @@ fn revocable(id: &str, _: &Value) -> photocraft_engine::Result<()> { /// Actual append/replace/Cancel work, and retained or stale consent cannot clear the library. #[test] -fn reset_placeholder_and_clear_all_keep_actual_consent_and_caller() { +fn reset_defaults_and_clear_all_keep_actual_consent_and_caller() { let mut h = harness(); let original = h.state().session.actions.list.clone(); open(&mut h, "Reset Actions…"); @@ -144,15 +144,16 @@ fn reset_placeholder_and_clear_all_keep_actual_consent_and_caller() { open(&mut h, "Reset Actions…"); h.get_by_label("OK").click(); h.run_steps(3); - assert_eq!(h.state().session.actions.list, original); - assert!(h.state().session.actions.sets.iter().any(|s| s.contains("placeholder"))); + assert_eq!(&h.state().session.actions.list[..original.len()], original.as_slice()); + assert_eq!(h.state().session.actions.list.len(), original.len() + 2); + assert!(h.state().session.actions.sets.iter().any(|s| s == actions_edit_cmds::DEFAULT_SET_NAME)); open(&mut h, "Reset Actions…"); h.get_by_label("Replace").click(); h.run_steps(3); h.get_by_label("OK").click(); h.run_steps(3); - assert!(h.state().session.actions.list.is_empty()); - assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); + assert_eq!(h.state().session.actions.list.len(), 2); + assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_NAME]); for label in ["Reset Actions…", "Clear All Actions"] { REVOKED.store(false, Ordering::SeqCst); let mut h = harness(); @@ -264,8 +265,8 @@ fn reset_offers_allowed_replace_when_append_is_denied() { let dialog = open(&mut h, "Reset Actions…"); assert_eq!(h.state_mut().ui.dialog_mut(dialog).unwrap().fields["mode"], "replace"); dialogs::confirm(h.state_mut(), dialog).unwrap(); - assert!(h.state().session.actions.list.is_empty()); - assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_PLACEHOLDER]); + assert_eq!(h.state().session.actions.list.len(), 2); + assert_eq!(h.state().session.actions.sets, [actions_edit_cmds::DEFAULT_SET_NAME]); } /// Only this test changes the policy; the same gate identity can revoke consent after opening. From cec845b784dd257db47c4924ba0ce5799316f6d7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Wed, 4 Nov 2026 13:39:55 -0800 Subject: [PATCH 138/384] Align portable native Actions export with import admission --- crates/engine/src/cmd/atn_cmds/convert.rs | 28 +++++++++---- .../engine/tests/it/atn_native_admission.rs | 15 ++++++- crates/engine/tests/support/atn_export.rs | 40 +++++++++++++++---- docs/atn.md | 10 +++-- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 87f832bf205..9ae7f67d69d 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -6,6 +6,13 @@ use crate::actions_cmds::{color_from_atn, color_to_atn}; // ------------------------------------------------------------------ import +/// Portable files admit the recorder's command surface and playback controls; unknown IDs stay opaque. +fn portable_native(id: &str) -> bool { + crate::commands::find(id).is_none() + || crate::actions_cmds::replayable(id) + || matches!(id, super::STOP_MESSAGE | super::PHOTOSHOP_STEP | "actions.conditional") +} + /// A step with no PhotoCraft equivalent. #[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct Unsupported { @@ -298,11 +305,7 @@ fn read_native(item: &AtnItem, input: NativeInput) -> std::result::Result