From 4165c57f92b90cb5308d812620d54078ee2c9452 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 11 Oct 2026 16:13:40 +0200 Subject: [PATCH 001/310] 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 3116e3e941..3dca7be0d2 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 e44dbe32a8..6942745852 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 7d9dac3c7a..38418749dd 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 3357d5465e..f189fd9cc6 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 002adc76db..872e7fce31 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 6c0f016464..17252d6b29 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 0000000000..e986512fc8 --- /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/310] 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 d2ee641b1e..350733493b 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/310] 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 1ad3f3aa3d..5008607ec1 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 e0f977d210..32d1d5fcf2 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 e986512fc8..ec76ff9f86 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 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 004/310] 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 750a9d5506..ddfaebe3e8 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 ff7ca5d292..d801985c64 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 7a5e499820..4267b7cbe0 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 25a286aab8..d1b357a7d6 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 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 005/310] 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 d4a3869f31..b7a5a4cb85 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 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 006/310] 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 b7a5a4cb85..e6c882d198 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 ae748f80cbd3143899f94661d6144dbdb6a5173c Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 03:23:53 +0400 Subject: [PATCH 007/310] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ka.tsv (4,165 entries: every key cargo xtask i18n-coverage measures, 1,844 / 1,844, plus the runtime labels the other complete catalogs carry), registered as ქართული with complete_menus and one count-neutral plural form; ka, ka-GE and ka_GE.UTF-8 resolve to it. Liquify's Rate slider is looked up in the liquify context, and the reason inside the web build's brush preset storage warning goes through i18n::t like its template. Attribution, licence note, Info.plist localizations, the coverage tool, localization.md, localization-parity.md, ROADMAP and Georgian notes (localization-ka.md). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- apps/photocraft-web/src/web.rs | 2 +- .../ui-egui/src/i18n/LICENSE-translations.txt | 5 +- crates/ui-egui/src/i18n/ka.tsv | 4225 +++++++++++++++++ crates/ui-egui/src/i18n/live_tests.rs | 1 + crates/ui-egui/src/i18n/mod.rs | 7 +- crates/ui-egui/src/i18n/system.rs | 1 + crates/ui-egui/src/liquify_ui.rs | 2 +- docs/localization-ka.md | 84 + docs/localization-parity.md | 12 +- docs/localization.md | 1 + packaging/macos/Info.plist.in | 1 + xtask/src/i18n_coverage.rs | 1 + 14 files changed, 4335 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ka.tsv create mode 100644 docs/localization-ka.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 227aee8ac9..346d704f4a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -14,6 +14,7 @@ next to them. | `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/ka.tsv` | Georgian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/pl.tsv` | Polish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..955266ff8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo | Korean | `ko` | 100% | partial | | Vietnamese | `vi` | 0% | none | -Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). +Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian, Georgian (9). ## Upcoming diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index a5c9d8a565..bc25591aa8 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -319,7 +319,7 @@ impl eframe::App for WebShell { self.presets.retry(); ui.ctx().request_repaint(); } - ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)])); + ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", i18n::t(&error))])); }); ui.label(if self.database.is_some() { i18n::t("Keep this tab open to retain your brushes.") diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index c33e7a21e9..2c427f752f 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,5 +1,5 @@ PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish, -Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. +Hungarian, Serbian and Georgian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -31,3 +31,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Serbian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Georgian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv new file mode 100644 index 0000000000..35ffc16100 --- /dev/null +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -0,0 +1,4225 @@ +# Georgian (ka) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); Georgian has one form: +# nouns stay singular after a numeral (`შრე: {n}`) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Georgian computing vocabulary (შრე, ნიღაბი, მონიშნული, ფუნჯი ...). No proprietary localisation +# resource, string table or product UI was consulted or copied; add entries the same way. +# Commands are verbal nouns (შენახვა, გახსნა); Georgian has no letter case, so nothing is +# capitalised (never Mtavruli). Product and technology names (PhotoCraft, OpenType, CMYK, Lab ...) +# stay as they are; the trailing "…" of dialog commands is kept. +# Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. + + Rows სტრიქონები + Number რაოდენობა + Margin ველები + Top: ზემოთ: + Left: მარცხნივ: + Bottom: ქვემოთ: + Right: მარჯვნივ: + Target: სამიზნე: + Selected Artboards მონიშნული სამუშაო დაფები + Center Columns სვეტების ცენტრირება + Clear Existing Guides არსებული დამხმარე ხაზების წაშლა + Invalid guide layout დამხმარე ხაზების არასწორი განლაგება + Leave empty for automatic size ავტომატური ზომისთვის დატოვეთ ცარიელი + 5 Columns, 3 Rows 5 სვეტი, 3 სტრიქონი + 8 Columns 8 სვეტი + 12 Columns 12 სვეტი + + File ფაილი + Edit რედაქტირება + Image გამოსახულება + Layer შრე + Type ტექსტი + Select მონიშვნა + Selected მონიშნული + Scope მოქმედების არე + Top Left ზედა მარცხენა + Top Right ზედა მარჯვენა + Bottom Left ქვედა მარცხენა + Bottom Right ქვედა მარჯვენა + Filter ფილტრი + View ხედი + Window ფანჯარა + Help დახმარება + (coming soon) (მალე) + New… ახალი… + Open… გახსნა… + Open As… გახსნა, როგორც… + Open EXR Parts… EXR ნაწილების გახსნა… + Add beauty precomp from the AOVs საბაზო კომპოზიციის (beauty) დამატება AOV-ებიდან + Both ორივე + Left მარცხნივ + Right მარჯვნივ + Open Recent ბოლოს გახსნილი ფაილების გახსნა + Clear Recent Files ბოლოს გახსნილი ფაილების სიის გასუფთავება + Remove from Recent ბოლოს გახსნილებიდან ამოშლა + Remove {name} from Recent {name}-ის ამოშლა ბოლოს გახსნილებიდან + Close დახურვა + Minimize ჩაკეცვა + Maximize გაშლა + Restore აღდგენა + Close All ყველას დახურვა + Close Others სხვების დახურვა + Save შენახვა + Save As… შენახვა როგორც… + Save a Copy… ასლის შენახვა… + Revert წინა ვერსიაზე დაბრუნება + Export ექსპორტი + Quick Export as PNG სწრაფი ექსპორტი PNG ფორმატში + Export As… ექსპორტი სხვა ფორმატით… + Export Preferences… ექსპორტის პარამეტრები… + Save for Web (Legacy)… ვებისთვის შენახვა (მოძველებული)… + Artboards to Files… სამუშაო დაფები ფაილებად… + Artboards to PDF… სამუშაო დაფები PDF-ად… + Layers to Files… შრეები ფაილებად… + Layer Comps to Files… შრეების კომპოზიციები ფაილებად… + Color Lookup Tables… ფერების ცხრილები (Color Lookup)… + Data Sets as Files… მონაცემთა ნაკრებები ფაილებად… + Paths to Illustrator… ტრაექტორიები Illustrator-ში… + Render Video… ვიდეოს რენდერი… + Generate გენერაცია + Image Assets გამოსახულების რესურსები + Place Embedded… ჩადგმულის განთავსება… + Place Linked… დაკავშირებულის განთავსება… + Package… შეფუთვა… + Automate ავტომატიზაცია + Batch… პაკეტური დამუშავება… + Create Droplet… დროპლეტის შექმნა… + Crop and Straighten Photos ფოტოების ჩამოჭრა და გასწორება + Contact Sheet II… საკონტაქტო ფურცელი II… + Conditional Mode Change… რეჟიმის პირობითი შეცვლა… + Fit Image… გამოსახულების ჩატევა… + Lens Correction… ობიექტივის კორექცია… + Merge to HDR Pro… HDR Pro-ში შერწყმა… + Photomerge… Photomerge… + Scripts სკრიპტები + Image Processor… გამოსახულებების დამმუშავებელი… + Delete All Empty Layers ყველა ცარიელი შრის წაშლა + Flatten All Layer Effects შრეების ყველა ეფექტის რასტრირება + Flatten All Masks ყველა ნიღბის გამოყენება + Script Events Manager… სკრიპტის მოვლენების მართვა… + Import იმპორტი + Browse… არჩევა… + Reveal in Finder Finder-ში ჩვენება + Show in Explorer Explorer-ში ჩვენება + Show in Folder საქაღალდეში ჩვენება + File Info… ფაილის ინფორმაცია… + Print… დაბეჭდვა… + Print One Copy ერთი ასლის დაბეჭდვა + Exit გასვლა + Undo დაბრუნება + Redo გამეორება + Toggle Last State ბოლო მდგომარეობის გადართვა + Fade… მილევა… + Cut ამოჭრა + Copy კოპირება + Copy Merged შერწყმულის კოპირება + Paste ჩასმა + Paste Special სპეციალური ჩასმა + Paste in Place ადგილზე ჩასმა + Paste Into შიგნით ჩასმა + Paste Outside გარეთ ჩასმა + Paste Lorem Ipsum Lorem Ipsum-ის ჩასმა + Clear გასუფთავება + Search… ძიება… + Check Spelling… მართლწერის შემოწმება… + Find and Replace Text… ტექსტის ძებნა და ჩანაცვლება… + Fill… შევსება… + Stroke… კონტური… + Content-Aware Fill… შიგთავსის გათვალისწინებით შევსება… + Delete and Fill Selection მონიშნულის წაშლა და შევსება + Content-Aware Scale შიგთავსის გათვალისწინებით მასშტაბირება + Puppet Warp მარიონეტული დეფორმაცია + Perspective Warp პერსპექტიული დეფორმაცია + Free Transform თავისუფალი გარდაქმნა + Transform გარდაქმნა + Again ხელახლა + Scale მასშტაბირება + Rotate შემობრუნება + Skew დახრა + Distort დამახინჯება + Perspective პერსპექტივა + Warp დეფორმაცია + Flip Horizontal ჰორიზონტალურად არეკვლა + Flip Vertical ვერტიკალურად არეკვლა + Rotate 180° 180°-ით შემობრუნება + Rotate 90° Clockwise 90°-ით შემობრუნება საათის ისრის მიმართულებით + Rotate 90° Counter Clockwise 90°-ით შემობრუნება საათის ისრის საწინააღმდეგოდ + 180° 180° + 90° Clockwise 90° საათის ისრის მიმართულებით + 90° Counter Clockwise 90° საათის ისრის საწინააღმდეგოდ + Arbitrary… ნებისმიერი… + Auto-Align Layers… შრეების ავტოსწორება… + Auto-Blend Layers… შრეების ავტოშერევა… + Define Brush Preset… ფუნჯის პრესეტის განსაზღვრა… + Define Pattern… პატერნის განსაზღვრა… + Define Custom Shape… მომხმარებლის ფიგურის განსაზღვრა… + Purge გაწმენდა + Clipboard გაცვლის ბუფერი + Histories ისტორიები + Video Cache ვიდეოს ქეში + All ყველა + Presets პრესეტები + Preset Manager… პრესეტების მართვა… + Migrate Presets პრესეტების მიგრაცია + Export/Import Presets… პრესეტების ექსპორტი/იმპორტი… + Color Settings… ფერის პარამეტრები… + Assign Profile… პროფილის მინიჭება… + Convert to Profile… პროფილში გარდაქმნა… + Keyboard Shortcuts… კლავიატურის მალსახმობები… + Menus… მენიუები… + Toolbar… ხელსაწყოთა ზოლი… + Preferences პარამეტრები + General… ზოგადი… + Interface… ინტერფეისი… + Workspace… სამუშაო სივრცე… + Tools… ხელსაწყოები… + History Log… ისტორიის ჟურნალი… + File Handling… ფაილებთან მუშაობა… + Export… ექსპორტი… + Performance… წარმადობა… + Scratch Disks… დროებითი დისკები… + Cursors… კურსორები… + Transparency & Gamut… გამჭვირვალობა და ფერთა დიაპაზონი… + Units & Rulers… ერთეულები და სახაზავები… + Guides, Grid & Slices… დამხმარე ხაზები, ბადე და ნაჭრები… + Plug-ins… პლაგინები… + Type… ტექსტი… + Enhanced Controls… გაუმჯობესებული მართვის ელემენტები… + Camera Raw… Camera Raw… + Raw Defaults… Raw-ის ნაგულისხმევი პარამეტრები… + Integrations… ინტეგრაციები… + AI Integrations… AI ინტეგრაციები… + Install Plug-in… პლაგინის დაყენება… + Language Options ენის პარამეტრები + Settings… პარამეტრები… + Mode რეჟიმი + Bitmap ბიტური + Grayscale ნაცრისფერი ტონები + Duotone ორტონიანი + Indexed Color ინდექსირებული ფერი + RGB Color RGB ფერი + CMYK Color CMYK ფერი + Lab Color Lab ფერი + Multichannel მრავალარხიანი + 8 Bits/Channel 8 ბიტი/არხი + 16 Bits/Channel 16 ბიტი/არხი + 32 Bits/Channel 32 ბიტი/არხი + Color Table… ფერების ცხრილი… + Bitmap… ბიტური… + Duotone… ორტონიანი… + Indexed Color… ინდექსირებული ფერი… + Adjustments კორექცია + Brightness/Contrast… სიკაშკაშე/კონტრასტი… + Levels… დონეები… + Curves… მრუდები… + Exposure… ექსპოზიცია… + Vibrance… სიხალასე… + Hue/Saturation… ფერის ტონი/გაჯერება… + Color Balance… ფერთა ბალანსი… + Black & White… შავ-თეთრი… + Photo Filter… ფოტოფილტრი… + Channel Mixer… არხების მიქშერი… + Color Lookup… ფერების ცხრილი (Color Lookup)… + Invert ინვერსია + Posterize… პოსტერიზაცია… + Threshold… ზღვარი… + Gradient Map… გრადიენტული რუკა… + Selective Color… შერჩევითი ფერი… + Shadows/Highlights… ჩრდილები/ღია ტონები… + HDR Toning… HDR ტონირება… + Desaturate გაუფერულება + Match Color… ფერის შეთანხმება… + Replace Color… ფერის ჩანაცვლება… + Equalize გათანაბრება + Auto Tone ავტოტონი + Auto Contrast ავტოკონტრასტი + Auto Color ავტოფერი + Image Size… გამოსახულების ზომა… + Canvas Size… ტილოს ზომა… + Image Rotation გამოსახულების შემობრუნება + Flip Canvas Horizontal ტილოს ჰორიზონტალურად არეკვლა + Flip Canvas Vertical ტილოს ვერტიკალურად არეკვლა + Crop ჩამოჭრა + Trim… შეკვეცა… + Reveal All ყველაფრის გამოჩენა + Duplicate… დუბლირება… + Apply Image… გამოსახულების გამოყენება… + Calculations… გამოთვლები… + Variables ცვლადები + Define… განსაზღვრა… + Data Sets… მონაცემთა ნაკრებები… + Apply Data Set… მონაცემთა ნაკრების გამოყენება… + Variable Data Sets… ცვლადი მონაცემების ნაკრებები… + Trap… ტრეპინგი… + Analysis ანალიზი + Set Measurement Scale… საზომი მასშტაბის დაყენება… + Select Data Points… მონაცემთა წერტილების არჩევა… + Record Measurements გაზომვების ჩაწერა + Ruler Tool სახაზავის ხელსაწყო + Count Tool დათვლის ხელსაწყო + Place Scale Marker… მასშტაბის მარკერის განთავსება… + Custom… მომხმარებლის… + New ახალი + Layer… შრე… + Group… ჯგუფი… + Group from Layers… ჯგუფი შრეებიდან… + Artboard… სამუშაო დაფა… + Artboard from Group… სამუშაო დაფა ჯგუფიდან… + Artboard from Layers… სამუშაო დაფა შრეებიდან… + Layer via Copy შრე კოპირებით + Layer via Cut შრე ამოჭრით + Layer From Background… შრე ფონიდან… + Layer from Background… შრე ფონიდან… + Background From Layer ფონი შრიდან + New Shape Layer ახალი ფიგურის შრე + New Type Layer ახალი ტექსტის შრე + New Fill Layer ახალი შევსების შრე + New Adjustment Layer ახალი მაკორექტირებელი შრე + New Layer Based Slice ახალი შრეზე დაფუძნებული ნაჭერი + New Smart Object via Copy ახალი ჭკვიანი ობიექტი კოპირებით + New Blank Video Layer ახალი ცარიელი ვიდეოშრე + New Video Layer from File… ახალი ვიდეოშრე ფაილიდან… + Duplicate Layer… შრის დუბლირება… + Delete Layer შრის წაშლა + Delete წაშლა + Rename Layer შრის სახელის შეცვლა + Create Layer შრის შექმნა + Layer Style შრის სტილი + Blending Options… შერევის პარამეტრები… + Bevel & Emboss… ფასკა და რელიეფი… + Inner Shadow… შიდა ჩრდილი… + Inner Glow… შიდა ნათება… + Satin… ატლასი… + Color Overlay… ფერის გადაფარვა… + Gradient Overlay… გრადიენტის გადაფარვა… + Pattern Overlay… პატერნის გადაფარვა… + Outer Glow… გარე ნათება… + Drop Shadow… ჩრდილი… + Copy Layer Style შრის სტილის კოპირება + Paste Layer Style შრის სტილის ჩასმა + Clear Layer Style შრის სტილის გასუფთავება + Global Light… გლობალური განათება… + Hide All Effects ყველა ეფექტის დამალვა + Scale Effects… ეფექტების მასშტაბირება… + Smart Filter ჭკვიანი ფილტრი + Convert for Smart Filters ჭკვიანი ფილტრებისთვის გარდაქმნა + Clear Smart Filters ჭკვიანი ფილტრების გასუფთავება + Disable Smart Filters ჭკვიანი ფილტრების გამორთვა + Delete Filter Mask ფილტრის ნიღბის წაშლა + Disable Filter Mask ფილტრის ნიღბის გამორთვა + Solid Color… მყარი ფერი… + Gradient… გრადიენტი… + Pattern… პატერნი… + Layer Mask შრის ნიღაბი + Vector Mask ვექტორული ნიღაბი + Reveal Selection მონიშნულის გამოჩენა + Hide Selection მონიშნულის დამალვა + Hide All ყველას დამალვა + From Transparency გამჭვირვალობიდან + Delete Vector Mask ვექტორული ნიღბის წაშლა + Enable Layer Mask შრის ნიღბის ჩართვა + Disable Layer Mask შრის ნიღბის გამორთვა + Enable Vector Mask ვექტორული ნიღბის ჩართვა + Link Layer Mask შრის ნიღბის დაკავშირება + Unlink Layer Mask შრის ნიღბის კავშირის გაწყვეტა + Link Vector Mask ვექტორული ნიღბის დაკავშირება + Add Vector Mask ვექტორული ნიღბის დამატება + Vector Mask from Current Path ვექტორული ნიღაბი მიმდინარე ტრაექტორიიდან + Create Clipping Mask მომჭრელი ნიღბის შექმნა + Release Clipping Mask მომჭრელი ნიღბის მოხსნა + Smart Objects ჭკვიანი ობიექტები + Smart Object ჭკვიანი ობიექტი + Convert to Smart Object ჭკვიან ობიექტად გარდაქმნა + Convert to Embedded ჩადგმულად გარდაქმნა + Convert to Layers შრეებად გარდაქმნა + Convert to Linked… დაკავშირებულად გარდაქმნა… + Edit Contents შიგთავსის რედაქტირება + Replace Contents… შიგთავსის ჩანაცვლება… + Relink to File… ფაილთან ხელახლა დაკავშირება… + Export Contents… შიგთავსის ექსპორტი… + Update Modified Content შეცვლილი შიგთავსის განახლება + Update All Modified Content ყველა შეცვლილი შიგთავსის განახლება + Stack Mode დასტის რეჟიმი + Entropy ენტროპია + Kurtosis ექსცესი + Maximum მაქსიმუმი + Mean საშუალო + Median მედიანა + Minimum მინიმუმი + Range დიაპაზონი + Skewness ასიმეტრია + Standard Deviation სტანდარტული გადახრა + Summation ჯამი + Variance დისპერსია + Video Layers ვიდეოშრეები + Interpret Footage… მასალის ინტერპრეტაცია… + Replace Footage… მასალის ჩანაცვლება… + Reload Frame კადრის ხელახლა ჩატვირთვა + Show Altered Video შეცვლილი ვიდეოს ჩვენება + Restore Frame კადრის აღდგენა + Restore All Frames ყველა კადრის აღდგენა + Insert Blank Frame ცარიელი კადრის ჩასმა + Delete Frame კადრის წაშლა + Duplicate Frame კადრის დუბლირება + Frame from Layers კადრი შრეებიდან + Video Frames to Layers… ვიდეოკადრები შრეებად… + Rasterize რასტრირება + Rasterize Type ტექსტის რასტრირება + Rasterize Type Layer ტექსტის შრის რასტრირება + Rasterize Shape ფიგურის რასტრირება + New Guide Layout… დამხმარე ხაზების ახალი განლაგება… + New Guides From Shape ახალი დამხმარე ხაზები ფიგურიდან + Lock Layers… შრეების ჩაკეტვა… + Link Layers შრეების დაკავშირება + Select Linked Layers დაკავშირებული შრეების მონიშვნა + Merge Layers შრეების შერწყმა + Merge Down ქვედასთან შერწყმა + Merge Visible ხილულების შერწყმა + Stamp Visible ხილულის ანაბეჭდი + Stamp Down ქვედაზე ანაბეჭდი + Flatten Image ერთ შრედ გაერთიანება + Matting კიდის ფონის მოცილება + Color Decontaminate… ფერის გაწმენდა… + Remove Black Matte შავი შემოსაზღვრის მოცილება + Remove White Matte თეთრი შემოსაზღვრის მოცილება + Defringe… კიდის შარავანდის მოცილება… + Group Layers შრეების დაჯგუფება + Ungroup Layers შრეების დაჯგუფების მოხსნა + Hide Layers შრეების დამალვა + Arrange დალაგება + Bring to Front წინა პლანზე გადმოტანა + Bring Forward წინ გადმოტანა + Send Backward უკან გადატანა + Send to Back უკანა პლანზე გადატანა + Reverse შებრუნება + Combine Shapes ფიგურების შეერთება + Unite Shapes ფიგურების გაერთიანება + Subtract Front Shape წინა ფიგურის გამოკლება + Intersect Shape Areas ფიგურების გადაკვეთის არე + Exclude Overlapping Shapes გადაფარული არეების გამორიცხვა + Merge Shape Components ფიგურის კომპონენტების შერწყმა + Align სწორება + Top Edges ზედა კიდეები + Vertical Centers ვერტიკალური ცენტრები + Bottom Edges ქვედა კიდეები + Left Edges მარცხენა კიდეები + Horizontal Centers ჰორიზონტალური ცენტრები + Right Edges მარჯვენა კიდეები + Distribute განაწილება + Vertically ვერტიკალურად + Horizontally ჰორიზონტალურად + Layer Edges შრის კიდეები + Isolate Layers შრეების იზოლირება + Layer Content Options… შრის შიგთავსის პარამეტრები… + Fill Content შიგთავსის შევსება + Deselect Layers შრეების მონიშვნის მოხსნა + Find Layers შრეების ძებნა + Layer Comps შრეების კომპოზიციები + Layers შრეები + All Layers ყველა შრე + Hidden Layers დამალული შრეები + Mask All Objects ყველა ობიექტის ნიღბით დაფარვა + Subject საგანი + Sky ცა + Panels პანელები + Character Panel სიმბოლოს პანელი + Paragraph Panel აბზაცის პანელი + Character Styles Panel სიმბოლოს სტილების პანელი + Paragraph Styles Panel აბზაცის სტილების პანელი + Glyphs Panel გლიფების პანელი + Anti-Alias დაგლუვება + Sharp მკვეთრი + Crisp მკაფიო + Strong ძლიერი + Smooth გლუვი + Windows LCD Windows LCD + Windows Windows + Orientation ორიენტაცია + Horizontal ჰორიზონტალური + Vertical ვერტიკალური + OpenType OpenType + Standard Ligatures სტანდარტული ლიგატურები + Contextual Alternates კონტექსტური ალტერნატივები + Discretionary Ligatures დამატებითი ლიგატურები + Swash მოხატული ასოები (swash) + Oldstyle ძველი სტილის ციფრები + Stylistic Alternates სტილისტური ალტერნატივები + Titling Alternates სათაურის ალტერნატივები + Ornaments ორნამენტები + Ordinals რიგობითი რიცხვები + Fractions წილადები + Create Work Path სამუშაო ტრაექტორიის შექმნა + Convert to Shape ფიგურად გარდაქმნა + Warp Text… ტექსტის დეფორმაცია… + Convert to Point Text წერტილოვან ტექსტად გარდაქმნა + Convert to Paragraph Text აბზაცის ტექსტად გარდაქმნა + Default Features ნაგულისხმევი შესაძლებლობები + East Asian Features აღმოსავლეთ აზიური შესაძლებლობები + Middle Eastern Features ახლო აღმოსავლური შესაძლებლობები + Middle Eastern & South Asian Composer აწყობა ახლო აღმოსავლეთისა და სამხრეთ აზიის დამწერლობებისთვის + Update All Text Layers ყველა ტექსტური შრის განახლება + Replace All Missing Fonts ყველა ნაკლული შრიფტის ჩანაცვლება + Resolve Missing Fonts… ნაკლული შრიფტების მოგვარება… + Font Preview Size შრიფტის წინასწარი ნახვის ზომა + None არცერთი + Small პატარა + Medium საშუალო + Large დიდი + Extra Large ძალიან დიდი + Huge უზარმაზარი + Load Default Type Styles ტექსტის ნაგულისხმევი სტილების ჩატვირთვა + Save Default Type Styles ტექსტის ნაგულისხმევი სტილების შენახვა + Deselect მონიშვნის მოხსნა + Reselect ხელახლა მონიშვნა + Inverse შებრუნება + Inverse Selection მონიშვნის შებრუნება + Similar მსგავსი + Color Range… ფერების დიაპაზონი… + Focus Area… ფოკუსის არე… + Select and Mask… მონიშვნა და ნიღაბი… +# Select and Mask dialog labels + Smart Radius ჭკვიანი რადიუსი + Shift Edge კიდის წანაცვლება + Decontaminate ფერის გაწმენდა + Refine Edge კიდის დაზუსტება + Modify შეცვლა + Border… საზღვარი… + Smooth… დაგლუვება… + Expand… გაფართოება… + Contract… შევიწროება… + Feather… კიდეების შერბილება… + Grow ზრდა + Transform Selection მონიშნულის გარდაქმნა + Edit in Quick Mask Mode სწრაფი ნიღბის რეჟიმში რედაქტირება + Load Selection… მონიშნულის ჩატვირთვა… + Save Selection… მონიშნულის შენახვა… + Match All ყველას შესაბამისობა + Last Filter ბოლო ფილტრი + Filter Gallery… ფილტრების გალერეა… + Adaptive Wide Angle… ადაპტიური ფართოკუთხა… + Camera Raw Filter… Camera Raw ფილტრი… + Liquify… გათხევადება… + Vanishing Point… გაქრობის წერტილი… + Blur გაბუნდოვნება + Blur Gallery გაბუნდოვნების გალერეა + Field Blur… ველის გაბუნდოვნება… + Iris Blur… დიაფრაგმის გაბუნდოვნება… + Tilt-Shift… დახრა-წანაცვლება… + Path Blur… ტრაექტორიის გასწვრივ გაბუნდოვნება… + Spin Blur… ბრუნვითი გაბუნდოვნება… + Blur More მეტად გაბუნდოვნება + Box Blur… ბლოკური გაბუნდოვნება… + Gaussian Blur… გაუსის გაბუნდოვნება… + Lens Blur… ლინზის ბუნდოვნება… + Motion Blur… მოძრაობის გაბუნდოვნება… + Radial Blur… რადიალური გაბუნდოვნება… + Shape Blur… ფიგურით გაბუნდოვნება… + Smart Blur… ჭკვიანი გაბუნდოვნება… + Surface Blur… ზედაპირის გაბუნდოვნება… + Average გასაშუალოება + Displace… წანაცვლება… + Pinch… შეკუმშვა… + Polar Coordinates… პოლარული კოორდინატები… + Ripple… ტალღოვნება… + Shear… ძვრა… + Spherize… სფერიზება… + Twirl… დაგრეხა… + Wave… ტალღა… + ZigZag… ზიგზაგი… + Noise ხმაური + Add Noise… ხმაურის დამატება… + Despeckle ლაქების მოცილება + Dust & Scratches… მტვერი და ნაკაწრები… + Median… მედიანა… + Reduce Noise… ხმაურის შემცირება… + Pixelate პიქსელიზაცია + Color Halftone… ფერადი ნახევარტონი… + Crystallize… კრისტალიზაცია… + Facet წახნაგი + Fragment ფრაგმენტი + Mezzotint… მეცოტინტა… + Mosaic… მოზაიკა… + Pointillize… პუანტილიზმი… + Render რენდერი + Clouds ღრუბლები + Difference Clouds სხვაობის ღრუბლები + Fibers… ბოჭკოები… + Flame… ალი… + Lens Flare… ობიექტივის ანარეკლი… + Lighting Effects… განათების ეფექტები… + Relight… ხელახალი განათება… + Picture Frame… სურათის ჩარჩო… + Tree… ხე… + Sharpen გამკვეთრება + Sharpen Edges კიდეების გამკვეთრება + Sharpen More მეტად გამკვეთრება + Smart Sharpen… ჭკვიანი გამკვეთრება… + Unsharp Mask… არამკვეთრი ნიღაბი… + High Pass… მაღალსიხშირული ფილტრი… + Stylize სტილიზაცია + Diffuse… დიფუზია… + Emboss… რელიეფი… + Extrude… ამოწევა… + Find Edges კიდეების პოვნა + Oil Paint… ზეთის საღებავი… + Solarize სოლარიზაცია + Tiles… ფილები… + Trace Contour… კონტურის გავლება… + Wind… ქარი… + Video ვიდეო + De-Interlace… სტრიქონგამოტოვების მოცილება… + NTSC Colors NTSC ფერები + Other სხვა + Maximum… მაქსიმუმი… + Minimum… მინიმუმი… + Offset… წანაცვლება… + Plug-ins პლაგინები + Proof Setup საცდელი ჩვენების პარამეტრები + Proof Colors ფერების საცდელი ჩვენება + Gamut Warning ფერთა გამიდან გასვლის გაფრთხილება + Working CMYK სამუშაო CMYK + Working Cyan Plate სამუშაო ცისფერი ფირფიტა + Working Magenta Plate სამუშაო მეწამული ფირფიტა + Working Yellow Plate სამუშაო ყვითელი ფირფიტა + Working Black Plate სამუშაო შავი ფირფიტა + Working CMY Plate სამუშაო CMY ფირფიტა + Legacy Macintosh RGB მოძველებული Macintosh RGB + Internet Standard RGB ინტერნეტის სტანდარტული RGB + Monitor RGB მონიტორის RGB + Color Blindness — Protanopia-type დალტონიზმი — პროტანოპიის ტიპი + Color Blindness — Deuteranopia-type დალტონიზმი — დეიტერანოპიის ტიპი + Zoom In გადიდება + Zoom Out დაპატარავება + Fit on Screen ეკრანში ჩატევა + Fit Layer(s) on Screen შრე(ებ)ის ეკრანში ჩატევა + Fit Artboard on Screen სამუშაო დაფის ეკრანში ჩატევა + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size საბეჭდი ზომა + Pixel Aspect Ratio პიქსელის გვერდების თანაფარდობა + Pixel Aspect Ratio Correction პიქსელის გვერდების თანაფარდობის კორექცია + Custom Pixel Aspect Ratio… პიქსელის გვერდების მომხმარებლის თანაფარდობა… + Square კვადრატული + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC ფართოეკრანიანი (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL ფართოეკრანიანი (1,46) + Anamorphic 2:1 (2) ანამორფული 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + 32-bit Preview Options… 32-ბიტიანი გადახედვის პარამეტრები… + Screen Mode ეკრანის რეჟიმი + Standard Screen Mode ეკრანის სტანდარტული რეჟიმი + Full Screen Mode With Menu Bar სრულეკრანიანი რეჟიმი მენიუს ზოლით + Full Screen Mode სრულეკრანიანი რეჟიმი + Cycle Screen Mode ეკრანის რეჟიმების მორიგეობით გადართვა + Show ჩვენება + Extras დამატებითი ელემენტები + Selection Edges მონიშნულის კიდეები + Target Path სამიზნე ტრაექტორია + Current Path მიმდინარე ტრაექტორია + Grid ბადე + Guides დამხმარე ხაზები + Canvas Guides ტილოს დამხმარე ხაზები + Artboard Guides სამუშაო დაფის დამხმარე ხაზები + Count დათვლა + Smart Guides ჭკვიანი დამხმარე ხაზები + Slices ნაჭრები + Notes შენიშვნები + Pixel Grid პიქსელური ბადე + Pixel Art პიქსელ-არტი + Pixel Art Preview პიქსელ-არტის გადახედვა + Pattern Preview პატერნის გადახედვა + Brush Preview ფუნჯის გადახედვა + Mesh დეფორმაციის ბადე + Edit Pins სამაგრების რედაქტირება + Document Bounds დოკუმენტის საზღვრები + Show Extras Options… დამატებითი ელემენტების ჩვენების პარამეტრები… + Rulers სახაზავები + Snap მიბმა + Snap To მიბმის ობიექტები + Lock Guides დამხმარე ხაზების ჩაკეტვა + Clear Guides დამხმარე ხაზების გასუფთავება + Clear Selected Artboard Guides მონიშნული სამუშაო დაფის დამხმარე ხაზების წაშლა + Clear Canvas Guides ტილოს დამხმარე ხაზების წაშლა + New Guide… ახალი დამხმარე ხაზი… + Lock Slices ნაჭრების ჩაკეტვა + Clear Slices ნაჭრების წაშლა + Match Zoom მასშტაბის შეთანხმება + Match Location მდებარეობის შეთანხმება + Match Rotation შემობრუნების შეთანხმება + Cascade კასკადი + Tile ფილებად + Tile All Horizontally ყველას ჰორიზონტალურად განლაგება + Tile All Vertically ყველას ვერტიკალურად განლაგება + 2-up Horizontal 2 ჰორიზონტალურად + 2-up Vertical 2 ვერტიკალურად + 3-up Horizontal 3 ჰორიზონტალურად + 3-up Stacked 3 დასტად + 3-up Vertical 3 ვერტიკალურად + 4-up 4 ფანჯარა + 6-up 6 ფანჯარა + Consolidate All to Tabs ყველას ჩანართებად გაერთიანება + Float in Window ცალკე ფანჯარაში გამოტანა + Float All in Windows ყველას მცურავ ფანჯრებში + New Window for Document დოკუმენტის ახალი ფანჯარა + Workspace სამუშაო სივრცე + Essentials ძირითადი + Graphic and Web გრაფიკა და ვები + Painting ხატვა + Photography ფოტოგრაფია + New Workspace… ახალი სამუშაო სივრცე… + Delete Workspace… სამუშაო სივრცის წაშლა… + Reset Workspace სამუშაო სივრცის საწყისზე დაბრუნება + Reset Tool ხელსაწყოს ჩამოყრა + Reset All Tools ყველა ხელსაწყოს ჩამოყრა + Reset all tools to their default settings? ჩამოიყაროს ყველა ხელსაწყო ნაგულისხმევ პარამეტრებზე? + Lock Workspace სამუშაო სივრცის ჩაკეტვა + Theme თემა + Pro Theme Pro თემა + Pro Medium Gray Theme Pro საშუალო ნაცრისფერი თემა + Pro Light Gray Theme Pro ღია ნაცრისფერი თემა + Pro Light Theme Pro ღია თემა + Studio Theme Studio თემა + Studio Light Theme Studio ღია თემა + Adwaita Light Theme Adwaita ღია თემა + Adwaita Dark Theme Adwaita ბნელი თემა + Classic Theme კლასიკური თემა + Solarized dark Solarized ბნელი + Solarized Dark Theme Solarized ბნელი თემა + Breeze dark Breeze ბნელი + Breeze Dark Theme Breeze ბნელი თემა + Breeze light Breeze ღია + Breeze Light Theme Breeze ღია თემა + Next Theme შემდეგი თემა + Actions მოქმედებები + Brush Settings ფუნჯის პარამეტრები + Brushes ფუნჯები + Channels არხები + Character სიმბოლო + Character Styles სიმბოლოს სტილები + Clone Source კლონირების წყარო + Color ფერი + Glyphs გლიფები + Gradients გრადიენტები + Histogram ჰისტოგრამა + History ისტორია + Info ინფორმაცია + Measurement Log გაზომვების ჟურნალი + Modifier Keys მოდიფიკატორი კლავიშები + Navigator ნავიგატორი + Options პარამეტრები + Paragraph აბზაცი + Paragraph Styles აბზაცის სტილები + Paths ტრაექტორიები + Patterns პატერნები + Properties თვისებები + Shapes ფიგურები + Styles სტილები + Swatches ფერის ნიმუშები + Timeline დროის ხაზი + Tool Presets ხელსაწყოს პრესეტები + Tools ხელსაწყოები + About PhotoCraft PhotoCraft-ის შესახებ + PhotoCraft Website PhotoCraft-ის ვებსაიტი + PhotoCraft on GitHub PhotoCraft GitHub-ზე + ArtCraft Website ArtCraft-ის ვებსაიტი + Join the ArtCraft Discord… შემოუერთდით ArtCraft-ის Discord-ს… + Report an Issue… ხარვეზის შეტყობინება… + WIA Support… WIA მხარდაჭერა… + Apply გამოყენება + HSB/HSL HSB/HSL + Invert… ინვერსია… + Load Files into Stack… ფაილების დასტაში ჩატვირთვა… + Motion მოძრაობა + Notes… შენიშვნები… + Other… სხვა… + Remove Warp Split დეფორმაციის გაყოფის მოცილება + Shape ფიგურა + Split Warp Crosswise დეფორმაციის ჯვარედინად გაყოფა + Split Warp Horizontally დეფორმაციის ჰორიზონტალურად გაყოფა + Split Warp Vertically დეფორმაციის ვერტიკალურად გაყოფა + Statistics… სტატისტიკა… + Refine Strokes დაზუსტების შტრიხები +@plural {n} item|{n} items {n} ელემენტი + Auto ავტომატური + (print to the printer) (პრინტერზე ბეჭდვა) + + Add variable + ცვლადის დამატება + 30 frames @ 30 fps 30 კადრი @ 30 fps + 3D LUT File 3D LUT ფაილი + Absolute აბსოლუტური + Absolute Colorimetric აბსოლუტური კოლორიმეტრიული + Action მოქმედება + Adaptive ადაპტური + Adaptive Wide Angle isn't open ადაპტიური ფართოკუთხა არ არის გახსნილი + Add დამატება + Add Layer Mask შრის ნიღბის დამატება + Add Stop გადასვლის წერტილის დამატება + Add a layer style შრის სტილის დამატება + Add a mask (from the selection; {key} inverts) ნიღბის დამატება (მონიშნულიდან; {key} აბრუნებს) + Add an adjustment კორექციის დამატება + Add disk დისკის დამატება + Add space after paragraph ინტერვალის დამატება აბზაცის შემდეგ + Add space before paragraph ინტერვალის დამატება აბზაცის წინ + Add to Sample ნიმუშში დამატება + Add to selection ({key}) მონიშნულში დამატება ({key}) + Adjust for Zoom მასშტაბის გათვალისწინება + Adjustment visible კორექცია ხილულია + Adobe RGB (1998) compatible Adobe RGB (1998)-თან თავსებადი + Align and Distribute სწორება და განაწილება + Align and distribute სწორება და განაწილება + Align horizontal centers ჰორიზონტალური ცენტრების სწორება + Align left edges მარცხენა კიდეების სწორება + Align right edges მარჯვენა კიდეების სწორება + Aligned გასწორებული + All Caps მთავრული + All Slices ყველა ნაჭერი + All User Slices მომხმარებლის ყველა ნაჭერი + All shortcuts reset to PhotoCraft defaults. ყველა მალსახმობი დაბრუნდა PhotoCraft-ის ნაგულისხმევზე. + Alt Alt + Always use pressure for opacity გაუმჭვირვალობისთვის ყოველთვის დაწოლის გამოყენება + Always use pressure for size ზომისთვის ყოველთვის დაწოლის გამოყენება + Amount რაოდენობა + Anchor: მიმაგრება: + Angle კუთხე + Angle Gradient კუთხური გრადიენტი + Angle Jitter კუთხის ცვალებადობა + Answer the unsaved-changes prompt first ჯერ უპასუხეთ შეუნახავი ცვლილებების შეკითხვას + Anti-alias დაგლუვება + Appearance იერსახე + Appearance (Layer Style) იერსახე (შრის სტილი) + Apply Layer Mask შრის ნიღბის გამოყენება + Apply Per Tip თითოეულ წვერზე გამოყენება + Apply next selected layer comp შემდეგი მონიშნული შრეების კომპოზიციის გამოყენება + Apply previous selected layer comp წინა მონიშნული შრეების კომპოზიციის გამოყენება + Artboard სამუშაო დაფა + Artboard Background Color სამუშაო დაფის ფონის ფერი + Assign working space სამუშაო სივრცის მინიჭება + Author: ავტორი: + Auto Hide ავტომატური დამალვა + Auto-Select: ავტომონიშვნა: + Automatically level near-horizontal line segments თითქმის ჰორიზონტალური მონაკვეთების ავტომატური გასწორება + Automatically straighten and level ავტომატური გასწორება და დონეზე მოყვანა + Automatically straighten near-vertical line segments თითქმის ვერტიკალური მონაკვეთების ავტომატური გასწორება + Background ფონი + Background Color ფონის ფერი + Background Contents ფონის შიგთავსი + Balance ბალანსი + Baseline საბაზისო ხაზი + Baseline shift საბაზისო ხაზის წანაცვლება + Basic საბაზო + Before (Y) მანამდე (Y) + Begin recording ჩაწერის დაწყება + Bend: მოხრა: + Bicubic ბიკუბური + Bicubic (smooth gradients) ბიკუბური (გლუვი გრადიენტებისთვის) + Automatic ავტომატური + Bicubic Smoother (enlargement) ბიკუბური, უფრო გლუვი (გადიდებისთვის) + Bicubic Sharper (reduction) ბიკუბური, უფრო მკვეთრი (შემცირებისთვის) + Bilinear ბიწრფივი + Black შავი + Black & White შავ-თეთრი + Black Point Compensation შავი წერტილის კომპენსაცია + Black, White შავი, თეთრი + Blacks შავები + Blending შერევა + Blending Options შერევის პარამეტრები + Blue ლურჯი + Blue, Red, Yellow ლურჯი, წითელი, ყვითელი + Both Axes ორივე ღერძი + Bottom ქვემოთ + Brightness სიკაშკაშე + Brightness Jitter სიკაშკაშის ცვალებადობა + Brightness/Contrast სიკაშკაშე/კონტრასტი + Bring the selected pin forward მონიშნული სამაგრის წინ გადმოტანა + Brush Preset picker ფუნჯის პრესეტის ამრჩევი + Brush Reconstruct Options ფუნჯით აღდგენის პარამეტრები + Brush Tool Options ფუნჯის ხელსაწყოს პარამეტრები + Cache Level: ქეშის დონე: + Camera Raw isn't open Camera Raw არ არის გახსნილი + Cancel გაუქმება + Cancel (Esc) გაუქმება (Esc) + Cancel any current edits (Esc) მიმდინარე ცვლილებების გაუქმება (Esc) + Cancel current crop operation (Esc) მიმდინარე ჩამოჭრის გაუქმება (Esc) + Cancel transform (Esc) გარდაქმნის გაუქმება (Esc) + Cancel warp (Esc) დეფორმაციის გაუქმება (Esc) + Canvas ტილო + Canvas extension color: ტილოს გაფართოების ფერი: + Capture ჩაწერა + Capture the visibility, position and style of every layer with + below, then switch between versions. + ღილაკით ქვემოთ ჩაწერეთ ყოველი შრის ხილვადობა, პოზიცია და სტილი, შემდეგ კი ვერსიებს შორის გადართეთ. + Catch-up on Stroke End მონასმის ბოლოს დაწევა + Center ცენტრში + Center Crop Marks ცენტრის ჭრის ნიშნები + Center text ტექსტის ცენტრირება + Change ჩანაცვლება + Change All ყველას ჩანაცვლება + Change Screen Mode (F) ეკრანის რეჟიმის შეცვლა (F) + Change To: ჩანაცვლება: + Channel Mixer არხების მიქშერი + Channel: არხი: + Check All Layers ყველა შრის შემოწმება + Check Spelling მართლწერის შემოწმება + Clarity სიცხადე + Clear All ყველას გასუფთავება + Clear Override გადაფარვის მოხსნა + Clear Samples ნიმუშების გასუფთავება + Click sets foreground · right-click sets background დაწკაპუნება აყენებს წინა პლანის ფერს · მარჯვენა დაწკაპუნება — ფონის ფერს + Click the first point or press {key} to close · Esc cancels დასახურად დაწკაპეთ პირველ წერტილზე ან დააჭირეთ {key}-ს · Esc აუქმებს + Click to add a point · drag off or {key}-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, {key}-დაწკაპეთ ან დააჭირეთ Delete-ს + Click to apply, {key}-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, {key}-დაწკაპუნება ხსნის გადაფარვებს, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to edit the gradient დაწკაპეთ გრადიენტის რედაქტირებისთვის + Click to restore the current colour დაწკაპეთ მიმდინარე ფერის აღსადგენად + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out დაწკაპეთ გასადიდებლად · {key}-დაწკაპუნება — დასაპატარავებლად · მარჯვნივ/მარცხნივ გადათრევა — გადიდება/დაპატარავება + Click with the Note tool to add a note. შენიშვნის დასამატებლად დაწკაპეთ შენიშვნის ხელსაწყოთი. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · {key} დასრულება · Esc გაუქმება + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · {alt}-გადათრევა: სახელურის გაწყვეტა · პირველ წერტილზე დაწკაპუნება ან გადათრევა: დახურვა · {key} დასრულება · Esc გაუქმება + Clip to layer below ქვედა შრეში ჩაჭრა + Clipped ჩაჭრილი + Close Style Options სტილის პარამეტრების დახურვა + Cmd Cmd + Coated CMYK ცარცული ქაღალდის CMYK + Color & Swatches ფერი და ფერის ნიმუშები + Color Balance ფერთა ბალანსი + Color Detail ფერის დეტალები + Color Grading ფერის კორექცია (grading) + Color Lookup ფერების ცხრილი (Color Lookup) + Color Management ფერების მართვა + Color Mixer ფერების მიქშერი + Color Mode ფერის რეჟიმი + Color Priority ფერის პრიორიტეტი + Color Range preview ფერების დიაპაზონის გადახედვა + Color: ფერი: + Colorize გაფერადება + Colors ფერები + Commit Perspective Warp ({key}) პერსპექტიული დეფორმაციის დადასტურება ({key}) + Commit Puppet Warp ({key}) მარიონეტული დეფორმაციის დადასტურება ({key}) + Commit any current edits ({key}) მიმდინარე ცვლილებების დადასტურება ({key}) + Commit current crop operation ({key}) მიმდინარე ჩამოჭრის დადასტურება ({key}) + Commit transform ({key}) გარდაქმნის დადასტურება ({key}) + Commit warp ({key}) დეფორმაციის დადასტურება ({key}) + Constant მუდმივი + Constrain proportions პროპორციების შენარჩუნება + Contact Sheet II საკონტაქტო ფურცელი II + Content-Aware შიგთავსის გათვალისწინებით + Contiguous მომიჯნავე + Contrast კონტრასტი + Control: მართვა: + Convert document's colors to the working space დოკუმენტის ფერების სამუშაო სივრცეში გარდაქმნა + Convert to sRGB sRGB-ად გარდაქმნა + Copies ასლები + Copper სპილენძი + Copyright საავტორო უფლება + Copyright and Contact Info საავტორო უფლება და საკონტაქტო ინფორმაცია + Corner Crop Marks კუთხის ჭრის ნიშნები + Corner radius კუთხის რადიუსი + Correction კორექცია + Count Jitter რაოდენობის ცვალებადობა + Create შექმნა + Create Droplet დროპლეტის შექმნა + Create Texture ტექსტურის შექმნა + Create Video Timeline ვიდეოს დროის ხაზის შექმნა + Create a new count group ახალი დათვლის ჯგუფის შექმნა + Create a new document or open an existing file. შექმენით ახალი დოკუმენტი ან გახსენით არსებული ფაილი. + Create a new group ახალი ჯგუფის შექმნა + Create a new layer ახალი შრის შექმნა + Create new action ახალი მოქმედების შექმნა + Create new channel ახალი არხის შექმნა + Create new document from current state ახალი დოკუმენტის შექმნა მიმდინარე მდგომარეობიდან + Create new fill or adjustment layer ახალი შევსების ან მაკორექტირებელი შრის შექმნა + Create new gradient ახალი გრადიენტის შექმნა + Create new group ახალი ჯგუფის შექმნა + Create new layer comp შრეების ახალი კომპოზიციის შექმნა + Create new pattern from the selection ახალი პატერნის შექმნა მონიშნულიდან + Create new shape from the current path ახალი ფიგურის შექმნა მიმდინარე ტრაექტორიიდან + Create new snapshot ახალი სნეპშოტის შექმნა + Create new style (from the selected type) ახალი სტილის შექმნა (მონიშნული ტექსტიდან) + Create new style from the selected layer ახალი სტილის შექმნა მონიშნული შრიდან + Create new tool preset ხელსაწყოს ახალი პრესეტის შექმნა + Crop Factor ჩამოჭრის კოეფიციენტი + Ctrl Ctrl + Current & Below მიმდინარე და ქვედა + Current Layer მიმდინარე შრე + Current Size მიმდინარე ზომა + Current Tool Only მხოლოდ მიმდინარე ხელსაწყო + Curve მრუდი + Curves მრუდები + Custom მორგებული + Custom Shapes მორგებული ფიგურები + Cyan ცისფერი + Cyan · Red ცისფერი · წითელი + Darken ჩამუქება + Darks მუქი ტონები + Data Sets მონაცემთა ნაკრებები + Default ნაგულისხმევი + Default colours (D) ნაგულისხმევი ფერები (D) + Default printer ნაგულისხმევი პრინტერი + Default restored. ნაგულისხმევი აღდგენილია. + Define განსაზღვრა + Define at least one variable first. ჯერ განსაზღვრეთ ერთი ცვლადი მაინც. + Dehaze ბურუსის მოცილება + Delete Cropped Pixels ჩამოჭრილი პიქსელების წაშლა + Delete Group ჯგუფის წაშლა + Delete Layer Mask შრის ნიღბის წაშლა + Delete Layers შრეების წაშლა + Delete Shortcut მალსახმობის წაშლა + Delete Timeline დროის ხაზის წაშლა + Delete Tool Preset ხელსაწყოს პრესეტის წაშლა + Delete count group დათვლის ჯგუფის წაშლა + Delete current channel მიმდინარე არხის წაშლა + Delete current state მიმდინარე მდგომარეობის წაშლა + Delete effect layer ეფექტის შრის წაშლა + +# Layers-panel trash confirmation + Delete layer ‘{name}’? წაიშალოს შრე ‘{name}’? + Delete {count} layers? {names} წაიშალოს შრეები ({count})? {names} + Delete layer? წაიშალოს შრე? + The document is closed დოკუმენტი დახურულია + The document or selected layers changed. Please request deletion again. დოკუმენტი ან მონიშნული შრეები შეიცვალა. გთხოვთ, წაშლა ხელახლა მოითხოვოთ. + Missing delete command წაშლის ბრძანება აკლია + Invalid delete command წაშლის ბრძანება არასწორია + Layer deleted, but confirmation preference could not be saved: {error} შრე წაიშალა, მაგრამ დადასტურების პარამეტრი ვერ შეინახა: {error} + + Delete layer შრის წაშლა + Delete layer comp შრეების კომპოზიციის წაშლა + Delete note შენიშვნის წაშლა + Delete selected measurements მონიშნული გაზომვების წაშლა + Delete style სტილის წაშლა + Delete tool preset ხელსაწყოს პრესეტის წაშლა + Density სიმკვრივე + Density: სიმკვრივე: + Depth სიღრმე + Depth Jitter სიღრმის ცვალებადობა + Description აღწერა + Deselect All ყველა მონიშვნის მოხსნა + Detail დეტალი + Diamond Gradient ბრილიანტისებრი გრადიენტი + Difference სხვაობა + Diffusion დიფუზია + Discard the embedded profile (don't color manage) ჩაშენებული პროფილის უგულებელყოფა (ფერების მართვის გარეშე) + Discontiguous არამომიჯნავე + Discord Discord + Dismiss დახურვა + Display P3 Display P3 + Display Text საჩვენებელი ტექსტი + Dither დიზერინგი + Divide… გაყოფა… + Document დოკუმენტი + Don't Save არ შეინახო + No არა + Yes დიახ + Done მზადაა + Dots წერტილები + Drag a rectangle around the object ობიექტის გარშემო მართკუთხედი გაავლეთ + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. შეზღუდვის დასამატებლად გამოსახულებაზე გაავლეთ; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად — მარჯვენა დაწკაპუნება. + Drag to move the active shape's path or the Work Path გადაათრიეთ აქტიური ფიგურის ტრაექტორიის ან სამუშაო ტრაექტორიის გადასაადგილებლად + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath დაწკაპუნება: საყრდენი წერტილის მონიშვნა · გადათრევა: საყრდენი წერტილის, სახელურის ან სეგმენტის გადაადგილება · {shift}-დაწკაპუნება: დამატება · {alt}-დაწკაპუნება: მთელი ქვეტრაექტორია + Draw with the Pen tool (P) or make a work path from a selection. დახაზეთ კალმით (P) ან შექმენით სამუშაო ტრაექტორია მონიშნულიდან. + Drop an image or PSD anywhere to open it. გასახსნელად ჩააგდეთ გამოსახულება ან PSD ნებისმიერ ადგილას. + Duplicate Layers… შრეების დუბლირება… + Duplicate style სტილის დუბლირება + Edit Toolbar… ხელსაწყოთა ზოლის რედაქტირება… + Edit in Quick Mask Mode (Q) რედაქტირება სწრაფი ნიღბის რეჟიმში (Q) + Edit in Standard Mode (Q) რედაქტირება სტანდარტულ რეჟიმში (Q) + Effects ეფექტები + Embed Color Profile ფერის პროფილის ჩაშენება + Embedded Profile Mismatch ჩაშენებული პროფილის შეუსაბამობა + Enable airbrush-style build-up effects აეროგრაფის სტილის დაგროვების ეფექტის ჩართვა + Enhance Edge კიდის გაძლიერება + Entire Font მთელი შრიფტი + Exact ზუსტი + Expansion: გაფართოება: + Export Presets ექსპორტის პრესეტები + Export selected measurements (CSV) მონიშნული გაზომვების ექსპორტი (CSV) + Exposure ექსპოზიცია + Exposure and Gamma ექსპოზიცია და გამა + Exposure: ექსპოზიცია: + Eyedropper პიპეტი + Factor: კოეფიციენტი: + Faux Bold ხელოვნური მუქი + Faux Italic ხელოვნური დახრილი + Feather კიდეების შერბილება + Fewer Points ნაკლები წერტილი + File Settings ფაილის პარამეტრები + Fill შევსება + Fill: შევსება: + Filter for adjustment layers მაკორექტირებელი შრეების ფილტრი + Filter for pixel layers პიქსელური შრეების ფილტრი + Filter for shape layers ფიგურის შრეების ფილტრი + Filter for smart objects ჭკვიანი ობიექტების ფილტრი + Filter for type layers ტექსტური შრეების ფილტრი + Filter: ფილტრი: + Finger Painting თითით ხატვა + Fisheye თევზის თვალი + Fit მორგება + Fit Screen ეკრანში ჩატევა + Fill Screen ეკრანის შევსება + Fit in View ხედში ჩატევა + Fixed ფიქსირებული + Fixed Ratio ფიქსირებული პროპორცია + Fixed Size ფიქსირებული ზომა + Flip არეკვლა + Flip X X-ით არეკვლა + Flip X Jitter X-ით არეკვლის ცვალებადობა + Flip Y Y-ით არეკვლა + Flip Y Jitter Y-ით არეკვლის ცვალებადობა + Flow ნაკადი + Flow Jitter ნაკადის ცვალებადობა + Focal Length ფოკუსური მანძილი + Font შრიფტი + Font Size: შრიფტის ზომა: + Font size შრიფტის ზომა + Foreground წინა პლანი + Foreground to Background წინა პლანიდან ფონისკენ + Foreground/Background Jitter წინა პლანის/ფონის ცვალებადობა + Format ფორმატი + Frame from Layers… კადრი შრეებიდან… + Free თავისუფალი + Full Spherical სრული სფერული + Fuzziness: გაბნეულობა: + GPU GPU + Gamma Correction გამა-კორექცია + Gamma: გამა: + Global გლობალური + Glow Color ნათების ფერი + Gradient Map გრადიენტული რუკა + Grain მარცვლოვნება + Gray ნაცრისფერი + Gray Gamma 2.2 Gray Gamma 2.2 + Green მწვანე + Group ჯგუფი + Hardness სიხისტე + Heart გული + Height სიმაღლე + Height: სიმაღლე: + Hide Auto Slices ავტომატური ნაჭრების დამალვა + Hide Properties თვისებების დამალვა + Highlight Compression ღია ტონების შეკუმშვა + Highlight Priority ღია ტონების პრიორიტეტი + Highlights ღია ტონები + Home საწყისი ეკრანი + Horizontal scale ჰორიზონტალური მასშტაბი + Hue ფერის ტონი + Hue Jitter ფერის ტონის ცვალებადობა + Hue/Saturation ფერის ტონი/გაჯერება + Hyphenate სიტყვების გადატანა + Ignore უგულებელყოფა + Ignore All ყველას გამოტოვება + Image Size გამოსახულების ზომა + Import Brushes… ფუნჯების იმპორტი… + Import brushes (.abr) ფუნჯების იმპორტი (.abr) + Import Presets პრესეტების იმპორტი + Imported იმპორტირებული + Indent First პირველი სტრიქონის შეწევა + Indent Left მარცხენა შეწევა + Indent Right მარჯვენა შეწევა + Indent first line პირველი სტრიქონის შეწევა + Indent left margin მარცხენა ველის შეწევა + Indent right margin მარჯვენა ველის შეწევა + Input: შემავალი: + Inside შიგნით + Interlaced ინტერლეისი + Interpolation: ინტერპოლაცია: + Intersect with selection ({key}) მონიშნულთან გადაკვეთა ({key}) + Invert All ყველაფრის ინვერსია + Invert has no settings. ინვერსიას პარამეტრები არ აქვს. + Italic დახრილი + Join us on Discord შემოგვიერთდით Discord-ზე + Justify all სრული ორმხრივი სწორება + Justify last centered ორმხრივი სწორება, ბოლო სტრიქონი ცენტრში + Justify last left ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ + Justify last right ორმხრივი სწორება, ბოლო სტრიქონი მარჯვნივ + Kerning კერნინგი + Keyboard Shortcuts კლავიატურის მალსახმობები + Keyboard Shortcuts and Menus კლავიატურის მალსახმობები და მენიუები + Kind სახეობა + Label Size წარწერის ზომა + Labels წარწერები + Lanczos (sharp) Lanczos (მკვეთრი) + Landscape ჰორიზონტალური + Last Document State დოკუმენტის ბოლო მდგომარეობა + Layout განლაგება + Leading სტრიქონთა დაშორება + Leading (set to the font size × auto-leading when Auto) სტრიქონთა დაშორება (Auto-ზე უდრის შრიფტის ზომა × ავტომატური დაშორება) + Leave as is (don't color manage) დატოვება როგორც არის (ფერების მართვის გარეშე) + Left align text ტექსტის მარცხნივ სწორება + Length: სიგრძე: + Level დონე + Levels დონეები + Lighten გაღიავება + Lightness სიღიავე + Lights ნათელი ტონები + Limits: ზღვრები: + Linear Gradient წრფივი გრადიენტი + Link layers შრეების დაკავშირება + Link width and height სიგანისა და სიმაღლის დაკავშირება + Liquify is not open გათხევადება არ არის გახსნილი + Load ჩატვირთვა + Load 3D LUT… 3D LUT-ის ჩატვირთვა… + Load channel as selection არხის ჩატვირთვა მონიშნულად + Load… ჩატვირთვა… + Localized Color Clusters ფერთა ლოკალური კლასტერები + Lock ჩაკეტვა + Lock all ყველაფრის ჩაკეტვა + Lock image pixels გამოსახულების პიქსელების ჩაკეტვა + Lock layer შრის ჩაკეტვა + Lock position პოზიციის ჩაკეტვა + Lock transparent pixels გამჭვირვალე პიქსელების ჩაკეტვა + Lock: ჩაკეტვა: + Logical Length: ლოგიკური სიგრძე: + Logical Units: ლოგიკური ერთეულები: + Luminance სიკაშკაშე + Luminance Detail სიკაშკაშის დეტალი + Magenta მეწამული + Magenta · Green მეწამული · მწვანე + Maintain aspect ratio თანაფარდობის შენარჩუნება + Marker Size მარკერის ზომა + Mask All ყველაფრის დაფარვა ნიღბით + Mask Options ნიღბის პარამეტრები + Masking ნიღბვა + Master საერთო + Matte მატი + Median: მედიანა: + Menus მენიუები + Mesh Size ბადის ზომა + Metadata მეტამონაცემები + Metrics მეტრიკა + Midpoint შუა წერტილი + Midtones შუალედური ტონები + Midtones from: შუალედური ტონები, საწყისი: + Midtones to: შუალედური ტონები, საბოლოო: + Minimum Depth მინიმალური სიღრმე + Minimum Diameter მინიმალური დიამეტრი + Minimum Roundness მინიმალური სიმრგვალე + Mode: რეჟიმი: + Monochrome მონოქრომული + More Points მეტი წერტილი + Move effect layer down ეფექტის შრის ჩამოწევა + Move effect layer up ეფექტის შრის აწევა + Move to გადატანა + Name სახელი + Name: სახელი: + Nearest Neighbor უახლოესი მეზობელი + Nearest Neighbor (hard edges) უახლოესი მეზობელი (მკვეთრი კიდეები) + New Size ახალი ზომა + New document… ახალი დოკუმენტი… + New effect layer ახალი ეფექტის შრე + New selection ახალი მონიშვნა + Next frame შემდეგი კადრი + Next note შემდეგი შენიშვნა + No Color ფერის გარეშე + No Color Management ფერების მართვის გარეშე + No Dither დიზერინგის გარეშე + No author ავტორის გარეშე + No character style სიმბოლოს სტილის გარეშე + No document დოკუმენტი არ არის + No matching commands შესაბამისი ბრძანება ვერ მოიძებნა + No presets for the current tool. მიმდინარე ხელსაწყოს პრესეტები არ არის. + No presets of this type. ამ ტიპის პრესეტები არ არის. + No properties თვისებები არ არის + No tool presets. ხელსაწყოს პრესეტები არ არის. + Noise Reduction ხმაურის შემცირება + Normal ჩვეულებრივი + Not in Dictionary: ლექსიკონში არ არის: + OK OK + Offset წანაცვლება + Only Web Colors მხოლოდ ვებ-ფერები + Opacity გაუმჭვირვალობა + Opacity Jitter გაუმჭვირვალობის ცვალებადობა + Opacity: გაუმჭვირვალობა: + Open a document to define variables. ცვლადების განსასაზღვრად გახსენით დოკუმენტი. + Open a document to select a colour range. ფერების დიაპაზონის მოსანიშნად გახსენით დოკუმენტი. + Open a document to use the timeline. დროის ხაზის გამოსაყენებლად გახსენით დოკუმენტი. + Optical ოპტიკური + Optimized ოპტიმიზებული + Option-click to define a source point to clone from Option-დაწკაპუნებით განსაზღვრეთ კლონირების საწყისი წერტილი + Original ორიგინალი + Output Channel: გამომავალი არხი: + Output Levels: გამომავალი დონეები: + Output: გამომავალი: + Outside გარეთ + Overlay Color გადაფარვის ფერი + Overlay Opacity გადაფარვის გაუმჭვირვალობა + Overlay: Rule of Thirds გადაფარვა: მესამედების წესი + Override Pressure დაწოლის უგულებელყოფა + Override Rotation შემობრუნების უგულებელყოფა + Override Tilt დახრის უგულებელყოფა + PRESET DETAILS პრესეტის დეტალები + PSD PSD + Paint Overlay საღებავით გადაფარვა + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. პანელების მდებარეობა ინახება სამუშაო სივრცეში. კლავიატურის მალსახმობები, მენიუები და ხელსაწყოთა ზოლი არასავალდებულოა. + Paper ქაღალდი + Path ტრაექტორია + Pattern პატერნი + Pattern Size პატერნის ზომა + Pause პაუზა + Percent პროცენტი + Perceptual აღქმითი + Perspective Warp is not active პერსპექტიული დეფორმაცია აქტიური არ არის + Perspective Warp: draw planes along the image's perspective, then switch to Warp პერსპექტიული დეფორმაცია: დახაზეთ სიბრტყეები გამოსახულების პერსპექტივის გასწვრივ, შემდეგ გადაერთეთ დეფორმაციაზე + Photo Filter ფოტოფილტრი + PhotoCraft PhotoCraft + PhotoCraft Manages Colors ფერებს მართავს PhotoCraft + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — ღია კოდის ნატიური გამოსახულების რედაქტორი, დაწერილი Rust-ზე. + Pin Depth: სამაგრის სიღრმე: + Pixel Length: სიგრძე პიქსელებში: + Pixels/Centimeter პიქსელი/სანტიმეტრი + Pixels/Inch პიქსელი/დუიმი + Pixels: პიქსელები: + Play დაკვრა + Play selection მონიშნულის დაკვრა + Portrait ვერტიკალური + Position პოზიცია + Position and Size პოზიცია და ზომა + Posterize პოსტერიზაცია + Preserve Details დეტალების შენარჩუნება + Preserve Luminosity სიკაშკაშის შენარჩუნება + Preset პრესეტი + Preset Type პრესეტის ტიპი + Preset: პრესეტი: + Presets: პრესეტები: + Press keys… დააჭირეთ კლავიშებს… + Pressure დაწოლა + Pressure for Opacity დაწოლა გაუმჭვირვალობისთვის + Pressure for Size დაწოლა ზომისთვის + Prevent auto-nesting in and out of Artboards and Frames სამუშაო დაფებსა და ჩარჩოებში ავტომატური ჩასმისა და ამოღების აკრძალვა + Preview წინასწარი ნახვა + Previous frame წინა კადრი + Previous note წინა შენიშვნა + Print დაბეჭდვა + Printer პრინტერი + Printer Manages Colors ფერებს მართავს პრინტერი + Printer Setup პრინტერის პარამეტრები + Printing Marks საბეჭდი ნიშნები + Progressive პროგრესული + Promote დაწინაურება + Protect Detail დეტალის დაცვა + Protect Foreground Color წინა პლანის ფერის დაცვა + Protect Tones ტონების დაცვა + Proximity Match სიახლოვით შესაბამისობა + Pulled String Mode დაჭიმული ძაფის რეჟიმი + Puppet Warp is not active მარიონეტული დეფორმაცია აქტიური არ არის + Purity სისუფთავე + Quality ხარისხი + Quick Actions სწრაფი მოქმედებები + Quick Export As PNG სწრაფი ექსპორტი PNG-ად + Radial Gradient რადიალური გრადიენტი + Radius რადიუსი + Radius: რადიუსი: + Range: დიაპაზონი: + Rate სიხშირე + Recently Used ბოლოს გამოყენებული + Reconstruct რეკონსტრუქცია + Record ● a sequence of edits, then play ▶ it on any document. ჩაწერეთ ● რედაქტირებების თანმიმდევრობა, შემდეგ დაუკარით ▶ ნებისმიერ დოკუმენტზე. + Red წითელი + Redefine style by current selection სტილის ხელახლა განსაზღვრა მიმდინარე მონიშნულით + Refine დაზუსტება + Reflected Gradient არეკლილი გრადიენტი + Registration Marks შეთავსების ნიშნები + Regular ჩვეულებრივი + Relative ფარდობითი + Relative Colorimetric შეფარდებით კოლორიმეტრული + Remove მოშორება + Remove All Pins ყველა სამაგრის მოშორება + Remove Background ფონის მოშორება + Remove Planes სიბრტყეების მოშორება + Remove Tool ხელსაწყოს მოშორება + Remove stop გადასვლის წერტილის წაშლა + Remove variable ცვლადის მოშორება + Rename გადარქმევა + Rename Group… ჯგუფის გადარქმევა… + Rename Layer… შრის სახელის შეცვლა… + Rename Tool Preset… ხელსაწყოს პრესეტის გადარქმევა… + Rename… გადარქმევა… + Resample ხელახალი დისკრეტიზაცია + Reset All to Defaults ყველაფრის ნაგულისხმევზე დაბრუნება + Import Shortcuts… მალსახმობების იმპორტი… + Load a keyboard shortcut set saved as .kys .kys ფაილად შენახული კლავიატურის მალსახმობების ნაკრების ჩატვირთვა + Imported {n} shortcuts from {file}. Click OK to keep them. {file}-დან იმპორტირებულია მალსახმობი: {n}. მათ შესანარჩუნებლად დაწკაპეთ OK-ზე. + {n} commands are not in PhotoCraft. PhotoCraft-ში არ არის ბრძანება: {n}. + Keyboard shortcuts imported კლავიატურის მალსახმობები იმპორტირებულია + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. აქ გამოიყენება თქვენი კლავიატურის მალსახმობების ნაკრები {set}: ნაგულისხმევისგან განსხვავდება მალსახმობი: {n}. მათ აჩვენებს რედაქტირება › კლავიატურის მალსახმობები; ყველაფრის ნაგულისხმევზე დაბრუნება ამას გააუქმებს. + Import keyboard shortcuts? მოხდეს კლავიატურის მალსახმობების იმპორტი? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft-მა ამ კომპიუტერზე იპოვა თქვენი კლავიატურის მალსახმობების ნაკრები {set} სხვა გამოსახულების რედაქტორიდან: PhotoCraft-ისგან განსხვავდება მისი მალსახმობი: {n}. მოხდეს იმპორტი? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. რედაქტირება › კლავიატურის მალსახმობები › ყველაფრის ნაგულისხმევზე დაბრუნება აუქმებს იმპორტს. + Don't Import იმპორტის გარეშე + The keyboard shortcut set was not imported: {error} კლავიატურის მალსახმობების ნაკრების იმპორტი ვერ მოხერხდა: {error} + Reset Section სექციის საწყისზე დაბრუნება + Reset Transform გარდაქმნის საწყისზე დაბრუნება + Reset to defaults ნაგულისხმევზე დაბრუნება + Resolution გარჩევადობა + Resolution: გარჩევადობა: + Restore All ყველაფრის აღდგენა + Restore Defaults ნაგულისხმევის აღდგენა + Restrictive (Web) შეზღუდული (ვები) + Right align text ტექსტის მარჯვნივ სწორება + Rigid ხისტი + Rotate: შემობრუნება: + Rotation შემობრუნება + Roughness უხეშობა + Roundness სიმრგვალე + Roundness Jitter სიმრგვალის ცვალებადობა + Rulers & Grids სახაზავები და ბადეები + Sample All Layers ყველა შრიდან ნიმუშის აღება + Sample: ნიმუში: + Sampling: Background Swatch ნიმუშის აღება: ფონის ფერის ნიმუში + Sampling: Continuous ნიმუშის აღება: უწყვეტი + Sampling: Once ნიმუშის აღება: ერთხელ + Saturate გაჯერება + Saturation გაჯერება + Saturation Jitter გაჯერების ცვალებადობა + Save HTML and Images HTML-ისა და გამოსახულებების შენახვა + Save as PDF PDF-ად შენახვა + Save selection as channel მონიშნულის არხად შენახვა + Save… შენახვა… + Scale to Fit Media მედიაში ჩატევით მასშტაბირება + Scatter გაბნევა + Scratch disks დროებითი დისკები + Search ძიება + Search commands ბრძანებების ძიება + Search commands, tools and panels… ბრძანებების, ხელსაწყოებისა და პანელების ძიება… + Search fonts შრიფტების ძიება + Select All ყველაფრის მონიშვნა + Select Subject ობიექტის მონიშვნა + Select a type layer or click into text to insert glyphs გლიფების ჩასასმელად მონიშნეთ ტექსტური შრე ან დაწკაპეთ ტექსტში + Select text to apply a character style სიმბოლოს სტილის გამოსაყენებლად მონიშნეთ ტექსტი + Select: მონიშვნა: + Selections მონიშნულები + Selective შერჩევითი + Selective Color შერჩევითი ფერი + Send the selected pin backward მონიშნული სამაგრის უკან გადატანა + Set additional smoothing options დაგლუვების დამატებითი პარამეტრები + Set background color ფონის ფერის დაყენება + Set foreground color წინა პლანის ფერის დაყენება + Set painting symmetry options ხატვის სიმეტრიის პარამეტრები + Symmetry Off სიმეტრია გამორთულია + Set shape fill type ფიგურის შევსების ტიპის დაყენება + Set shape stroke type ფიგურის კონტურის ტიპის დაყენება + Set the text color ტექსტის ფერის დაყენება + Shadows ჩრდილები + Shape: ფორმა: + Sharpening გამკვეთრება + Shift Shift + Shortcut removed. მალსახმობი მოშორებულია. + Show All Menu Items მენიუს ყველა პუნქტის ჩვენება + Show Backdrop ფონის ჩვენება + Show Guides დამხმარე ხაზების ჩვენება + Show Mask ნიღბის ჩვენება + Show Mesh ბადის ჩვენება + Show Overlay გადაფარვის ჩვენება + Show Transform Controls გარდაქმნის მართვის ელემენტების ჩვენება + Show or hide the Notes panel შენიშვნების პანელის ჩვენება ან დამალვა + Show: ჩვენება: + Sides გვერდები + Sides: გვერდები: + Size ზომა + Size Jitter ზომის ცვალებადობა + Size: ზომა: + Slice Options… ნაჭრის პარამეტრები… + Slices From Guides ნაჭრები დამხმარე ხაზებიდან + Slices are locked (View › Lock Slices) ნაჭრები ჩაკეტილია (ხედი › ნაჭრების ჩაკეტვა) + Small Caps პატარა მთავრული + Smoothing დაგლუვება + Space After ინტერვალი შემდეგ + Space Before ინტერვალი წინ + Spacing ინტერვალი + Spell check complete. მართლწერის შემოწმება დასრულდა. + Split: გაყოფა: + Star ratio ვარსკვლავის თანაფარდობა + Stop playing/recording დაკვრის/ჩაწერის შეჩერება + Straighten გასწორება + Straighten Layer შრის გასწორება + Strength: სიძლიერე: + Strikethrough გადახაზული + Stroke კონტური + Stroke Catch-up მონასმის დაწევა + Stroke: კონტური: + Location: მდებარეობა: + Style სტილი + Style Options სტილის პარამეტრები + Style Options… სტილის პარამეტრები… + Style: Normal სტილი: ჩვეულებრივი + Subtract from Sample ნიმუშს გამოკლება + Subtract from selection ({key}) მონიშნულს გამოკლება ({key}) + Suggestions: შეთავაზებები: + Swap colours (X) ფერების გაცვლა (X) + Swaps height and width სიმაღლისა და სიგანის გაცვლა + Switch between free transform and warp modes თავისუფალი გარდაქმნისა და დეფორმაციის რეჟიმებს შორის გადართვა + Switch theme თემის გადართვა + Temperature ტემპერატურა + Tetrahedral ტეტრაედრული + Text Position: ტექსტის პოზიცია: + Texture ტექსტურა + Texture Each Tip ტექსტურა თითოეულ წვერზე + Threshold ზღვარი + Threshold Level ზღვრის დონე + Thumbnail size (smallest: list) ესკიზის ზომა (უმცირესი: სია) + Tilt X X-დახრა + Tilt Y Y-დახრა + Tint ტონირება + Toggle count group visibility დათვლის ჯგუფის ხილვადობის გადართვა + Toggle text orientation ტექსტის ორიენტაციის გადართვა + Tolerance დაშვება + Tolerance: დაშვება: + Tone: ტონი: + Toolbar ხელსაწყოთა ზოლი + Top ზემოთ + Total: სულ: + Tracking ტრეკინგი + Tracking (1/1000 em) ტრეკინგი (1/1000 em) + Transparency გამჭვირვალობა + Transparent გამჭვირვალე + Trilinear სამწრფივი + Type Styles ტექსტის სტილები + Type: ტიპი: + Underline ხაზგასმული + Untitled უსათაურო + Untitled-1 უსათაურო-1 + Update layer comp (visibility, position and appearance) შრეების კომპოზიციის განახლება (ხილვადობა, პოზიცია და იერსახე) + Use Default ნაგულისხმევის გამოყენება + Use Legacy ძველი ვერსიის გამოყენება + Use Measurement Scale საზომი მასშტაბის გამოყენება + Use the embedded profile (instead of the working space) ჩაშენებული პროფილის გამოყენება (სამუშაო სივრცის ნაცვლად) + Variables bind a layer's visibility, text or pixels to a named data slot. ცვლადები შრის ხილვადობას, ტექსტს ან პიქსელებს აკავშირებს მონაცემთა დასახელებულ ველთან. + Vertical scale ვერტიკალური მასშტაბი + Vibrance სიხალასე + View Options ხედის პარამეტრები + Vignetting ვინიეტირება + Violet, Orange იისფერი, ნარინჯისფერი + Visibility ხილვადობა + Visible ხილული + Warp: დეფორმაცია: + Web Snap ვებ-ფერებზე მიბმა + Weight სისქე + Weight: სისქე: + White თეთრი + White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია + Whites თეთრები + Width სიგანე + Slant დახრა + Width: სიგანე: + Work Path სამუშაო ტრაექტორია + Yellow ყვითელი + Yellow · Blue ყვითელი · ლურჯი + Zoom მასშტაბი + Zoom in გადიდება + Zoom out დაპატარავება + command or shortcut ბრძანება ან მალსახმობი + current მიმდინარე + image file path გამოსახულების ფაილის მისამართი + modified შეცვლილი + name სახელი + new ახალი + {name} options {name}: პარამეტრები + ↑↓ navigate {key} run esc close ↑↓ ნავიგაცია {key} გაშვება esc დახურვა + Version {version} ვერსია {version} + New Document ახალი დოკუმენტი + New Document With Mask ახალი დოკუმენტი ნიღბით + New Layer With Mask ახალი შრე ნიღბით + Preset Name პრესეტის სახელი + No saved presets yet. შენახული პრესეტები ჯერ არ არის. + Error შეცდომა + Command ბრძანება + Adjustment layers have nothing to transform მაკორექტირებელ შრეებში გარდასაქმნელი არაფერია + Altitude სიმაღლე + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45mm + Aqua ზღვისფერი + Art & Illustration ხელოვნება და ილუსტრაცია + ArtCraft ArtCraft + Background Eraser Tool ფონის საშლელის ხელსაწყო + Bevel & Emboss ფასკა და რელიეფი + Blend Mode შერევის რეჟიმი + Blues ლურჯები + Blur Tool გაბუნდოვნების ხელსაწყო + Bold მუქი + Brush ფუნჯი + Block ბლოკი + Brush Pose ფუნჯის პოზა + Brush Tip Shape ფუნჯის წვერის ფორმა + Brush Tool ფუნჯის ხელსაწყო + Build-up აეროგრაფი + Burn Tool გამამუქებლის ხელსაწყო + Centimeters სანტიმეტრი + Choke შევიწროება + Classic კლასიკური + Click to add text დაწკაპეთ ტექსტის დასამატებლად + Click to fill similar colours დაწკაპეთ მსგავსი ფერების შესავსებად + Clone Stamp Tool კლონირების შტამპის ხელსაწყო + Color Burn ფერით ჩამუქება + Color Dodge ფერით გაღიავება + Color Dynamics ფერის დინამიკა + Color Overlay ფერის გადაფარვა + Comic Book კომიქსი + Conform მორგება + Content-Aware Fill needs a selection on a pixel layer შიგთავსის გათვალისწინებით შევსებას სჭირდება მონიშნული პიქსელურ შრეზე + Could not transform: the layer is empty გარდაქმნა ვერ მოხერხდა: შრე ცარიელია + Could not use Perspective Warp: the layer is empty პერსპექტიული დეფორმაცია ვერ გამოიყენება: შრე ცარიელია + Could not use Puppet Warp: no opaque pixels მარიონეტული დეფორმაცია ვერ გამოიყენება: გაუმჭვირვალე პიქსელები არ არის + Could not use Puppet Warp: the layer is empty მარიონეტული დეფორმაცია ვერ გამოიყენება: შრე ცარიელია + Crop Tool ჩამოჭრის ხელსაწყო + Crosswise განივად + Current Tool მიმდინარე ხელსაწყო + Custom Shape Tool მორგებული ფიგურის ხელსაწყო + Cyans ცისფრები + Default PhotoCraft Size ნაგულისხმევი ზომა + Delete Workspace სამუშაო სივრცის წაშლა + Diamond ბრილიანტი + Dimensions: ზომები: + Direction მიმართულება + Distance მანძილი + Document Dimensions დოკუმენტის ზომები + Document Profile დოკუმენტის პროფილი + Document Sizes დოკუმენტის ზომები + Dodge Tool გამაღიავებლის ხელსაწყო + Down ქვემოთ + Drag to draw a gradient გადაათრიეთ გრადიენტის დასახაზად + Drag to move the active layer გადაათრიეთ აქტიური შრის გადასაადგილებლად + Drag to pan · hold Space with any tool გადაათრიეთ ხედის გადასაწევად · ან ნებისმიერ ხელსაწყოსთან დააჭირეთ Space-ს + Drop Shadow ჩრდილი + Dual Brush ორმაგი ფუნჯი + Edge კიდე + Efficiency ეფექტიანობა + Efficiency: 100% ეფექტიანობა: 100% + Ellipse Tool ელიფსის ხელსაწყო + Elliptical Marquee Tool ელიფსური მონიშვნის ხელსაწყო + Single Row Marquee Tool ერთი სტრიქონის მონიშვნის ხელსაწყო + Single Column Marquee Tool ერთი სვეტის მონიშვნის ხელსაწყო + Emboss რელიეფი + Eraser საშლელი + Eraser Tool საშლელის ხელსაწყო + ExtraBold ძალიან მუქი + ExtraLight ძალიან ღია + Eyedropper Tool პიპეტის ხელსაწყო + Fade მილევა + Fill Opacity შევსების გაუმჭვირვალობა + Film & Video კინო და ვიდეო + From საწყისი + General ზოგადი + GitHub GitHub + Gradient Overlay გრადიენტის გადაფარვა + Gradient Tool გრადიენტის ხელსაწყო + Greens მწვანეები + Hand Tool ხელის ხელსაწყო + Hard მკვეთრი + Hard Mix მკვეთრი შერევა + Healing Brush Tool აღმდგენი ფუნჯის ხელსაწყო + Highlights from: ღია ტონები, საწყისი: + History Brush Tool ისტორიის ფუნჯის ხელსაწყო + Horizontal Type Tool ჰორიზონტალური ტექსტის ხელსაწყო + Horizontal Type Mask Tool ჰორიზონტალური ტექსტური ნიღბის ხელსაწყო + Vertical Type Mask Tool ვერტიკალური ტექსტური ნიღბის ხელსაწყო + Image Size: გამოსახულების ზომა: + Inches დუიმი + Indexed ინდექსირებული + Initial Direction საწყისი მიმართულება + Inner Bevel შიდა ფასკა + Inner Glow შიდა ნათება + Inner Shadow შიდა ჩრდილი + Lab Lab + Landscape, 10 x 8 ჰორიზონტალური, 10 x 8 + Landscape, 6 x 4 ჰორიზონტალური, 6 x 4 + Landscape, 7 x 5 ჰორიზონტალური, 7 x 5 + Lasso ლასო + Lasso Tool ლასოს ხელსაწყო + Layer Count შრეების რაოდენობა + Layer Knocks Out Drop Shadow შრე ჩრდილს ამოკვეთს + Legal Legal + Letter Letter + Light ღია + Line Tool ხაზის ხელსაწყო + Linear წრფივი + Linear Burn წრფივი ჩამუქება + Linear Height წრფივი სიმაღლე + Link with Layer შრესთან დაკავშირება + Lorem Ipsum Lorem Ipsum + Magentas მეწამულები + Magic Eraser Tool ჯადოსნური საშლელის ხელსაწყო + Magic Wand Tool ჯადოსნური ჯოხის ხელსაწყო + Marquee მონიშვნა + Mean: საშუალო: + Measurement Scale საზომი მასშტაბი + Measurement Scale Marker საზომი მასშტაბის მარკერი + Millimeters მილიმეტრი + Mobile მობილური + Move Tool გადაადგილების ხელსაწყო + Could not use the move tool because the layer is locked. გადაადგილების ხელსაწყო ვერ გამოიყენება, რადგან შრე ჩაკეტილია. + Could not complete the Free Transform command because the layer is locked. თავისუფალი გარდაქმნის ბრძანება ვერ შესრულდა, რადგან შრე ჩაკეტილია. + Multiply გამრავლება + My Brushes ჩემი ფუნჯები + Neutrals ნეიტრალურები + New Workspace ახალი სამუშაო სივრცე + Note Tool შენიშვნის ხელსაწყო + Object Selection Tool ობიექტის მონიშვნის ხელსაწყო + Off გამორთული + Open გახსნა + Orange ნარინჯისფერი + Original Ratio საწყისი პროპორცია + Out Of Gamut ფერთა დიაპაზონს გარეთ + Outer Bevel გარე ფასკა + Outer Glow გარე ნათება + Overlay გადაფარვა + Paint Bucket Tool საღებავის ვედროს ხელსაწყო + Paints from the document's opening state ხატავს დოკუმენტის გახსნისდროინდელი მდგომარეობიდან + Path Selection Tool ტრაექტორიის მონიშვნის ხელსაწყო + Direct Selection Tool პირდაპირი მონიშვნის ხელსაწყო + Add Anchor Point Tool საყრდენი წერტილის დამატების ხელსაწყო + Delete Anchor Point Tool საყრდენი წერტილის წაშლის ხელსაწყო + Click a segment to add an anchor point · {alt}-click an anchor to delete it საყრდენი წერტილის დასამატებლად დაწკაპეთ სეგმენტზე · წასაშლელად — {alt}-დაწკაპუნება საყრდენ წერტილზე + Click an anchor point to delete it · {alt}-click a segment to add one წასაშლელად დაწკაპეთ საყრდენ წერტილზე · დასამატებლად — {alt}-დაწკაპუნება სეგმენტზე + Pattern Overlay პატერნის გადაფარვა + Pen Pressure კალმის დაწოლა + Pen Tilt კალმის დახრა + Pen Tool კალმის ხელსაწყო + Convert Point Tool წერტილის გარდაქმნის ხელსაწყო + Photo ფოტო + PhotoCraft website PhotoCraft-ის ვებსაიტი + Picas პიკა + Pillow Emboss ბალიშისებრი რელიეფი + Pixel Replacement პიქსელების ჩანაცვლება + Pixels პიქსელი + Points პუნქტი + Polygon Tool მრავალკუთხედის ხელსაწყო + Polygonal Lasso Tool მრავალკუთხა ლასოს ხელსაწყო + Portrait, 4 x 6 ვერტიკალური, 4 x 6 + Portrait, 5 x 7 ვერტიკალური, 5 x 7 + PostScript (72 points/inch) PostScript (72 პუნქტი/დუიმი) + Postcard ღია ბარათი + Poster პოსტერი + Pro (Dark) Pro (ბნელი) + Pro (Light) Pro (ღია) + Pro (Light Gray) Pro (ღია ნაცრისფერი) + Pro (Medium Gray) Pro (საშუალო ნაცრისფერი) + Protect Texture ტექსტურის დაცვა + Purple იისფერი + Quick Mask სწრაფი ნიღაბი + Quick Selection Tool სწრაფი მონიშვნის ხელსაწყო + Radial რადიალური + Rasterize Layer შრის რასტრირება + Rasterize Layers შრეების რასტრირება + Ratio პროპორცია + Recent ბოლოდროინდელი + Rectangle Tool მართკუთხედის ხელსაწყო + Rectangular Marquee Tool მართკუთხა მონიშვნის ხელსაწყო + Reds წითლები + Reflected არეკლილი + Sample: foreground colour ნიმუში: წინა პლანის ფერი + Sampled Colors ნიმუშად აღებული ფერები + Satin ატლასი + Save Pixel Aspect Ratio პიქსელის გვერდების თანაფარდობის შენახვა + Scattering გაბნევა + Scratch Sizes დროებითი დისკის ზომები + Select Data Points მონაცემთა წერტილების მონიშვნა + Selection მონიშნული + SemiBold ნახევრად მუქი + Shadows up to: ჩრდილები, ზედა ზღვარი: + Shape Dynamics ფორმის დინამიკა + Sharpen Tool გამკვეთრების ხელსაწყო + Show Extras Options დამატებითი ელემენტების პარამეტრების ჩვენება + Slice Select Tool ნაჭრის მონიშვნის ხელსაწყო + Slice Tool ნაჭრის ხელსაწყო + Smart Filters ჭკვიანი ფილტრები + Smudge Tool გადღაბნის ხელსაწყო + Soft რბილი + Soften დარბილება + Source წყარო + Source: set წყარო: დაყენებულია + Sponge Tool ღრუბლის ხელსაწყო + Spot Healing Brush Tool წერტილოვანი აღმდგენი ფუნჯის ხელსაწყო + Spread გავრცობა + Square, 12 x 12 კვადრატი, 12 x 12 + Square, 5 x 5 კვადრატი, 5 x 5 + Std Dev: სტანდ. გადახრა: + Studio (Dark) Studio (ბნელი) + Studio (Light) Studio (ღია) + Stylus Wheel სტილუსის ბორბალი + Subtract გამოკლება + Tabloid Tabloid + Text Replacement ტექსტის ჩანაცვლება + Thin თხელი + Traditional (72.27 points/inch) ტრადიციული (72.27 პუნქტი/დუიმი) + Transfer გადაცემა + Triangle Tool სამკუთხედის ხელსაწყო + Unsaved changes შეუნახავი ცვლილებები + Uses the foreground and background colours. იყენებს წინა პლანისა და ფონის ფერებს. + Web ვები + Web Large ვები, დიდი + Web Minimum ვები, მინიმალური + Web Most Common ვები, ყველაზე გავრცელებული + WebP (lossless) WebP (უდანაკარგო) + Wet Edges სველი კიდეები + Yellows ყვითლები + Zoom Tool მასშტაბის ხელსაწყო + {name} Properties {name}: თვისებები + Revert “{name}” to the last saved version? Your changes will be lost. დაუბრუნდეს „{name}“ ბოლოს შენახულ ვერსიას? თქვენი ცვლილებები დაიკარგება. + Do you want to save the changes you made to “{name}” before quitting? გსურთ, გასვლამდე შეინახოთ „{name}“-ში შეტანილი ცვლილებები? + Do you want to save the changes you made to “{name}” before closing? გსურთ, დახურვამდე შეინახოთ „{name}“-ში შეტანილი ცვლილებები? + Doc: {flat}/{layered} დოკ.: {flat}/{layered} + Scratch: {size} დროებითი: {size} + Untagged {mode} {mode} ტეგის გარეშე + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch გარჩევადობა: {dpi} პიქსელი/დუიმი + Doc: {w} × {h} px დოკ.: {w} × {h} px + Frame {current} / {total} კადრი {current} / {total} + Count: {n} რაოდენობა: {n} + BLANK DOCUMENT PRESETS ({n}) ცარიელი დოკუმენტის პრესეტები ({n}) + Constraints ({n}) შეზღუდვები ({n}) + Align {what} სწორება: {what} +@plural {n} Layer|{n} Layers შრე: {n} +@plural {n} layer|{n} layers შრე: {n} +@plural Group · {n} layer|Group · {n} layers ჯგუფი · შრე: {n} + Additional plugins folder პლაგინების დამატებითი საქაღალდე + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control აგენტის მიერ მართვის დაშვება + Allow scripts to connect სკრიპტების დაკავშირების დაშვება + Always ყოველთვის + Always create smart objects when placing განთავსებისას ყოველთვის ჭკვიანი ობიექტების შექმნა + Animated zoom ანიმირებული მასშტაბირება + Apply auto tone ავტოტონის გამოყენება + Ask კითხვა + Ask before saving layered tiff შრეებიანი TIFF-ის შენახვამდე კითხვა + Auto collapse icon panels ხატულების პანელების ავტომატური აკეცვა + Auto show hidden panels დამალული პანელების ავტომატური ჩვენება + Auto show home screen საწყისი ეკრანის ავტომატური ჩვენება + Autosave ავტოშენახვა + Autosave minutes ავტოშენახვის ინტერვალი (წუთი) + Beep when done დასრულებისას ხმოვანი სიგნალი + Bicubic automatic ბიკუბური, ავტომატური + Bicubic sharper ბიკუბური, უფრო მკვეთრი + Bicubic smoother ბიკუბური, უფრო გლუვი + Bit depth ბიტური სიღრმე + Brush preview color ფუნჯის წინასწარი ნახვის ფერი + Cache levels ქეშის დონეები + Cache tile size ქეშის ფილის ზომა + Camera Raw Defaults Camera Raw-ის ნაგულისხმევი პარამეტრები + Canvas border ტილოს საზღვარი + Show canvas dots ტილოს წერტილების ჩვენება + Canvas color ტილოს ფერი + Canvas custom color ტილოს მორგებული ფერი + Color picker ფერის ამრჩევი + Color space ფერთა სივრცე + Column width სვეტის სიგანე + Concise მოკლე + Control port მართვის პორტი + Convert to srgb sRGB-ად გარდაქმნა + Cursors კურსორები + Custom dark მორგებული ბნელი + Custom light მორგებული ღია + Dark ბნელი + Dark gray მუქი ნაცრისფერი + Dashed lines წყვეტილი ხაზები + Destination დანიშნულება + Detailed დეტალური + Disks დისკები + Display p3 Display P3 + Double click layer mask launches select and mask შრის ნიღაბზე ორმაგი დაწკაპუნება ხსნის „მონიშვნა და ნიღაბს“ + Drop shadow ჩრდილი + Dynamic color sliders ფერის დინამიკური სლაიდერები + East asian აღმოსავლეთაზიური + Effect cache mb ეფექტების ქეში (მბ) + Enable flick panning ინერციული გადაწევის ჩართვა + Enable floating document window docking მცურავი დოკუმენტის ფანჯრების მიმაგრების ჩართვა + Enable narrow options bar ვიწრო პარამეტრების ზოლის ჩართვა + Enabled ჩართულია + Enhanced Controls გაუმჯობესებული მართვის ელემენტები + Export clipboard გაცვლის ბუფერის ექსპორტი + Extra large ძალიან დიდი + File Handling ფაილებთან მუშაობა + File path ფაილის მისამართი + Fill new type layers with placeholder ახალი ტექსტური შრეების შევსება ჩანაცვლების ტექსტით + Font preview შრიფტის წინასწარი ნახვა + Full size tip სრული ზომის წვერი + Gamut warning color ფერთა გამიდან გასვლის გაფრთხილების ფერი + Gamut warning opacity ფერთა გამიდან გასვლის გაფრთხილების გაუმჭვირვალობა + Generator enabled გენერატორი ჩართულია + Gif GIF + Glossy პრიალა + Grid color ბადის ფერი + Grid colors ბადის ფერები + Grid size ბადის ზომა + Grid style ბადის სტილი + Grid unit ბადის ერთეული + Gridline every ბადის ხაზი ყოველ + Guide color დამხმარე ხაზის ფერი + Guide style დამხმარე ხაზის სტილი + Guides, Grid & Slices დამხმარე ხაზები, ბადე და ნაჭრები + Gutter შუალედი + History Log ისტორიის ჟურნალი + History states ისტორიის მდგომარეობები + Ignore exif profile tag EXIF პროფილის ტეგის უგულებელყოფა + Image interpolation გამოსახულების ინტერპოლაცია + Image previews გამოსახულების მინიატურები + Integrations ინტეგრაციები + Interface ინტერფეისი + Jpeg quality JPEG-ის ხარისხი + Jpg JPG + Language ენა + Large tabs დიდი ჩანართები + Legacy compositing ძველი კომპოზიტინგი + Light gray ღია ნაცრისფერი + Line ხაზი + Lines ხაზები + Lossless უდანაკარგო + Lowercase extension გაფართოება პატარა ასოებით + Maximize psd compatibility PSD-თავსებადობის მაქსიმიზაცია + Medium gray საშუალო ნაცრისფერი + Memory usage mb მეხსიერების გამოყენება (მბ) + Low resolution previews დაბალი გარჩევადობის მინიატურები + Missing glyph protection დაკარგული გლიფებისგან დაცვა + Modern თანამედროვე + Nearest neighbor უახლოესი მეზობელი + Never არასოდეს + Normal tip ჩვეულებრივი წვერი + Open as smart object ჭკვიან ობიექტად გახსნა + Open in camera raw Camera Raw-ში გახსნა + Open documents as tabs დოკუმენტების ჩანართებად გახსნა + Overscroll საზღვრებს მიღმა გადახვევა + Performance წარმადობა + Png PNG + Point size პუნქტის ზომა + Precise ზუსტი + Preserve details დეტალების შენარჩუნება + Print resolution ბეჭდვის გარჩევადობა + Pro Pro + Pro light Pro ღია + Pro light gray Pro ღია ნაცრისფერი + Pro medium Pro საშუალო + Pro photo ProPhoto + Quick export format სწრაფი ექსპორტის ფორმატი + Quick export location სწრაფი ექსპორტის მდებარეობა + Recent file count ბოლოს გახსნილი ფაილების რაოდენობა + Recent files ბოლოს გახსნილი ფაილები + Recent fonts ბოლოდროინდელი შრიფტები + Recover on launch გაშვებისას აღდგენა + Remember workspace changes სამუშაო სივრცის ცვლილებების დამახსოვრება + Resize image during place ჩასმისას გამოსახულების ზომის შეცვლა + Rotate view with trackpad ხედის შემობრუნება სენსორული პანელით + Same folder იგივე საქაღალდე + Save in background ფონურ რეჟიმში შენახვა + Scratch Disks დროებითი დისკები + Screen ეკრანი + Screen resolution ეკრანის გარჩევადობა + Scrubby slider acceleration მცოცავი სლაიდერის აჩქარება + Sessions only მხოლოდ სესიები + Sharpen for გამკვეთრება: + Show channels in color არხების ფერად ჩვენება + Show crosshair in brush tip ფუნჯის წვერში ჯვრის ჩვენება + Show extension panels გაფართოების პანელების ჩვენება + Show font names in english შრიფტების სახელების ინგლისურად ჩვენება + Show menu colors მენიუს ფერების ჩვენება + Show only crosshair while painting ხატვისას მხოლოდ ჯვრის ჩვენება + Show slice numbers ნაჭრების ნომრების ჩვენება + Show tooltips მინიშნებების ჩვენება + Show highlight on rollover მიტანისას გამოკვეთის ჩვენება + Show transformation values გარდაქმნის მნიშვნელობების ჩვენება + Slice color ნაჭრის ფერი + Smart guide color ჭკვიანი დამხმარე ხაზების ფერი + Smart quotes ჭკვიანი ბრჭყალები + Snap vector tools and transforms to pixel grid ვექტორული ხელსაწყოებისა და გარდაქმნების პიქსელურ ბადეზე მიბმა + Srgb sRGB + Standard სტანდარტული + Studio სტუდია + Studio light სტუდია ღია + Adwaita Adwaita + Adwaita dark Adwaita ბნელი + Subdivisions ქვედანაყოფები + System სისტემა + Text engine ტექსტის ძრავა + Text file ტექსტური ფაილი + Tiny ძალიან პატარა + Touch gestures სენსორული ჟესტები + Transparency & Gamut გამჭვირვალობა და ფერთა დიაპაზონი + Type units ტექსტის ერთეულები + UI font size ინტერფეისის შრიფტის ზომა + UI scale ინტერფეისის მასშტაბი + Units & Rulers ერთეულები და სახაზავები + Use additional plugins folder პლაგინების დამატებითი საქაღალდის გამოყენება + Use esc to commit დადასტურება Esc-ით + Use gpu GPU-ის გამოყენება + Use legacy free transform ძველი თავისუფალი გარდაქმნის გამოყენება + Use shift key for tool switch ხელსაწყოს გადართვა Shift კლავიშით + Vary round brush hardness on hud მრგვალი ფუნჯის სიხისტის ცვლა HUD-ზე + Webp WebP + World ready მსოფლიო (World-Ready) + Zoom clicked point to center დაწკაპუნებული წერტილის ცენტრში მოქცევა მასშტაბირებისას + Zoom resizes windows მასშტაბირება ფანჯრების ზომას ცვლის + Zoom with scroll wheel მასშტაბირება გორგოლაჭით + Zoom with trackpad pinch მასშტაბირება სენსორულ პანელზე თითების მოჭერით + Pass Through გატარება + Dissolve გაფანტვა + Darker Color უფრო მუქი ფერი + Linear Dodge (Add) წრფივი გაღიავება (დამატება) + Lighter Color უფრო ღია ფერი + Soft Light რბილი სინათლე + Hard Light მკვეთრი სინათლე + Vivid Light კაშკაშა სინათლე + Linear Light წრფივი სინათლე + Pin Light წერტილოვანი სინათლე + Exclusion გამორიცხვა + Divide გაყოფა + Luminosity ნათება +@plural {n} sample|{n} samples {n} ნიმუში + {added}, {removed} subtracted {added}, გამოკლებულია {removed} + Adjustment Preview კორექციის გადახედვა + Airbrush: keeps depositing paint while the pointer rests. აეროგრაფი: საღებავის დატანა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. + Angle: კუთხე: + Blend If შერევა, თუ + Close Tab Group ჩანართების ჯგუფის დახურვა + Collapse ჩაკეცვა + Collapse All Groups ყველა ჯგუფის ჩაკეცვა + Collapse Panel Group პანელების ჯგუფის ჩაკეცვა + Collapse group ({key}-click: all groups) ჯგუფის ჩაკეცვა ({key}+დაწკაპუნება: ყველა ჯგუფი) + Create new brush from the current settings ახალი ფუნჯის შექმნა მიმდინარე პარამეტრებით + Create new brush from these settings ახალი ფუნჯის შექმნა ამ პარამეტრებით + Update the current brush with these settings მიმდინარე ფუნჯის განახლება ამ პარამეტრებით + Delete brush ფუნჯის წაშლა + Drag the arrow to set the angle, the dots to set the roundness გადაათრიეთ ისარი კუთხის დასაყენებლად, წერტილები — სიმრგვალისთვის + Expand გაშლა + Expand Panel Group პანელების ჯგუფის გაშლა + Expand group ({key}-click: all groups) ჯგუფის გაშლა ({key}+დაწკაპუნება: ყველა ჯგუფი) + Fade steps მილევის ბიჯები + Hide the layer's effects ({key}-click: all layers) შრის ეფექტების დამალვა ({key}+დაწკაპუნება: ყველა შრე) + Import Brushes… (.abr) ფუნჯების იმპორტი… (.abr) + Move Group Down ჯგუფის ჩამოწევა + Move Group Up ჯგუფის აწევა + Reset the brush to the defaults ფუნჯის ნაგულისხმევ პარამეტრებზე დაბრუნება + Restore original size საწყისი ზომის აღდგენა + Roundness: სიმრგვალე: + Search Brushes ფუნჯების ძიება + Show the layer's effects ({key}-click: all layers) შრის ეფექტების ჩვენება ({key}+დაწკაპუნება: ყველა შრე) + Brush picker ფუნჯის ამრჩევი + Erase წაშლა + Right click with painting tools მარჯვენა დაწკაპუნება სახატავი ხელსაწყოებით + System Info… სისტემის ინფორმაცია… + System Info სისტემის ინფორმაცია + More panels სხვა პანელები + More documents სხვა დოკუმენტები + These settings aren't available in PhotoCraft yet. ეს პარამეტრები PhotoCraft-ში ჯერ ხელმისაწვდომი არ არის. + Graphics გრაფიკა + Reset GPU Backend GPU-ის ბექენდის ჩამოყრა + Applies at next launch. ძალაში შედის შემდეგი გაშვებისას. + Click to sample the foreground colour · {key}-click for background დაწკაპეთ წინა პლანის ფერის ნიმუშის ასაღებად · {key}+დაწკაპუნება — ფონისთვის + {key}-click to set the source {key}+დაწკაპუნება წყაროს დასაყენებლად + {key} to subtract {key} — გამოსაკლებად + Clone source {n} ({key}-click with Clone Stamp to set) კლონირების წყარო {n} (დასაყენებლად {key}+დაწკაპუნება კლონირების შტამპით) + Source: not set ({key}-click with Clone Stamp) წყარო: არ არის დაყენებული ({key}+დაწკაპუნება კლონირების შტამპით) + Pixel Layer პიქსელური შრე + Type Layer ტექსტური შრე + Shape Layer ფიგურის შრე + Group Layer ჯგუფის შრე + Fill Layer შევსების შრე + Smart Object Layer ჭკვიანი ობიექტის შრე + Create Warped Text დეფორმირებული ტექსტის შექმნა + Rasterize Smart Object ჭკვიანი ობიექტის რასტრირება + Rasterize Fill Content შევსების შიგთავსის რასტრირება + Rasterize Layer Style შრის სტილის რასტრირება + Unlock: picking a preset replaces these settings განბლოკვა: პრესეტის არჩევა ამ პარამეტრებს ჩაანაცვლებს + Lock: keep these settings when picking another preset ჩაკეტვა: ამ პარამეტრების შენარჩუნება სხვა პრესეტის არჩევისას + Diameter: დიამეტრი: + Hardness: სიხისტე: + No brush presets ფუნჯის პრესეტები არ არის + Toggle the Brush Settings panel (F5) ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა (F5) + Create new brush preset from the current settings ფუნჯის ახალი პრესეტის შექმნა მიმდინარე პარამეტრებით + Brush Settings… (F5): every option of this brush ფუნჯის პარამეტრები… (F5): ამ ფუნჯის ყველა პარამეტრი + Rename Brush… ფუნჯის გადარქმევა… + Delete Brush ფუნჯის წაშლა + New Brush Preset… ფუნჯის ახალი პრესეტი… + Brush Preset Options ფუნჯის პრესეტის პარამეტრები + Group name ჯგუფის სახელი + Brush name ფუნჯის სახელი + Brush Name ფუნჯის სახელი + Brush Stroke ფუნჯის მონასმი + Off: the speed of the pointer sets the spacing გამორთული: ინტერვალს მაჩვენებლის სიჩქარე განსაზღვრავს + Tilt Scale დახრის მასშტაბი + Brush Projection ფუნჯის პროექცია + Apply the pen's tilt and rotation to the tip shape კალმის დახრისა და შემობრუნების გამოყენება წვერის ფორმაზე + Mixer Brush შემრევი ფუნჯი + Wet სინოტივე + Mix მიქსი + Wetness Jitter სინოტივის ცვალებადობა + Mix Jitter მიქსის ცვალებადობა + Show the layer mask as an overlay შრის ნიღბის გადაფარვად ჩვენება + Toggle visibility ხილვადობის ჩართვა/გამორთვა + Unlink the mask from the layer ნიღბის შრისგან გათიშვა + Link the mask to the layer ნიღბის შრესთან დაკავშირება + Gradient გრადიენტი + Gradient Options გრადიენტის პარამეტრები + Align with layer შრესთან სწორება + Reset Alignment სწორების ჩამოყრა + Centre the gradient (offset 0, 0) გრადიენტის ცენტრირება (წანაცვლება 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor გრადიენტის რედაქტორი + Gradient picker გრადიენტის ამრჩევი + Gradient stops გრადიენტის გადასვლის წერტილები + Save As Preset პრესეტად შენახვა + The gradient the Gradient tool paints with გრადიენტი, რომლითაც გრადიენტის ხელსაწყო ხატავს + Adds the current gradient to the Gradients panel მიმდინარე გრადიენტის დამატება გრადიენტების პანელში + The current gradient (pick one in Window › Gradients) მიმდინარე გრადიენტი (აირჩიეთ ფანჯარა › გრადიენტები) + Select a type layer to edit its character and paragraph settings. სიმბოლოსა და აბზაცის პარამეტრების შესაცვლელად მონიშნეთ ტექსტური შრე. + Text color ტექსტის ფერი + Type Options ტექსტის პარამეტრები + Anti-aliasing method დაგლუვების მეთოდი + Horizontal type ჰორიზონტალური ტექსტი + Vertical type ვერტიკალური ტექსტი + Fill path with foreground color ტრაექტორიის წინა პლანის ფერით შევსება + Stroke path with brush ტრაექტორიის კონტური ფუნჯით + Load path as a selection ტრაექტორიის მონიშნულად ჩატვირთვა + Make work path from selection მონიშნულიდან სამუშაო ტრაექტორიის შექმნა + Add vector mask ვექტორული ნიღბის დამატება + Create new path ახალი ტრაექტორიის შექმნა + Delete path ტრაექტორიის წაშლა + Delete vector mask ვექტორული ნიღბის წაშლა + CPU (no GPU acceleration) CPU (GPU-აჩქარების გარეშე) + DirectX 12 DirectX 12 + GPU backend GPU ბექენდი + Linux display server Linux-ის ეკრანის სერვერი + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure პლანშეტის დაწოლის გამოყენება + Vulkan Vulkan + Alt-click to define a source point to clone from Alt+დაწკაპუნებით მიუთითეთ კლონირების საწყისი წერტილი + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. მონიშნულის შესაქმნელად ტრაექტორია არ არის: დახაზეთ კალმით ან მონიშნეთ ტრაექტორიების პანელში. + Cancel opening გახსნის გაუქმება + Cancelled opening {name} {name}-ის გახსნა გაუქმდა + Developing {name} მუშავდება {name} + Cancelled {label} გაუქმდა: {label} + Esc to cancel Esc — გასაუქმებლად + Opened {name} გაიხსნა {name} + Opening {name} იხსნება {name} + Saved {name} შენახულია {name} + Saving {name} ინახება {name} + A save to {name} is still running; try again when it finishes {name}-ში შენახვა ჯერ კიდევ მიმდინარეობს; სცადეთ მისი დასრულების შემდეგ + {label} is writing the file and can no longer be cancelled {label} ფაილს წერს და მისი გაუქმება აღარ შეიძლება + Opening {name}… იხსნება {name}… + Opening… იხსნება… + Reading… იკითხება… + Working… მუშავდება… +@id select.all ყველაფრის მონიშვნა +@id edit.purge.undo დაბრუნების ისტორია + Delete Smart Filters ჭკვიანი ფილტრების წაშლა + Enable Smart Filters ჭკვიანი ფილტრების ჩართვა + New Layer… ახალი შრე… + Delete Layer… შრის წაშლა… + Color Range ფერების დიაპაზონი + Select Similar Colors მსგავსი ფერების მონიშვნა + New Selection ახალი მონიშვნა + Adaptive Wide Angle ადაპტიური ფართოკუთხა + Chalk & Charcoal ცარცი და ნახშირი + Charcoal ნახშირი + Chrome ქრომი + Conté Crayon კონტეს ფანქარი + Graphic Pen გრაფიკული კალამი + Halftone Pattern ნახევარტონური პატერნი + Note Paper ქაღალდი ჩანაწერებისთვის + Photocopy ფოტოასლი + Plaster თაბაშირი + Reticulation რეტიკულაცია + Stamp შტამპი + Torn Edges დახეული კიდეები + Water Paper სველი ქაღალდი + Canvas rotation ტილოს შემობრუნება + Extensions გაფართოებები + Libraries ბიბლიოთეკები + 3D 3D + Toolbars ხელსაწყოთა ზოლები + Move გადაადგილება + Wand ჯოხი + Pencil ფანქარი + Clone Stamp კლონირების შტამპი + Healing აღდგენა + Dodge გამაღიავებელი + Burn გამამუქებელი + Pen კალამი + Hand ხელი + Sampler სინჯის ამღები + Note tool შენიშვნის ხელსაწყო + Quick Selection სწრაფი მონიშვნა + Magnetic Lasso მაგნიტური ლასო + History Brush ისტორიის ფუნჯი + Art History Brush მხატვრული ისტორიის ფუნჯი + Pattern Stamp პატერნის შტამპი + Spot Healing წერტილოვანი აღდგენა + Red Eye წითელი თვალები + Patch საკერებელი + Content-Aware Move შიგთავსის გათვალისწინებით გადაადგილება + Rotate View ხედის შემობრუნება + Perspective Crop პერსპექტიული ჩამოჭრა + Color Replacement ფერის ჩანაცვლება + Dodge/Burn გამაღიავებელი/გამამუქებელი + Dynamics დინამიკა + Tilt დახრა + Reset ჩამოყრა + Defaults restored. ნაგულისხმევი პარამეტრები აღდგენილია. + Stop შეწყვეტა + Create new set ახალი ნაკრების შექმნა + Technology Previews ტექნოლოგიების წინასწარი ვერსიები + New Group ახალი ჯგუფი + Layer Comp შრეების კომპოზიცია + Create new layer ახალი შრის შექმნა + Group · {n} layer ჯგუფი · {n} შრე + General Blend ზოგადი შერევა + Advanced Blending გაფართოებული შერევა + RGB RGB + Alpha ალფა + Spot Channel სპოტ-ფერის არხი + Working Gray Plate სამუშაო რუხი ფირფიტა + HSL/Grayscale HSL/ნაცრისფერი ტონები + Tone Curve ტონალური მრუდი + Split Toning გაყოფილი ტონირება + Lens Corrections ობიექტივის კორექციები + Camera Calibration კამერის კალიბრაცია + Snapshots სნეპშოტები + Recovery აღდგენა + Fill Light შევსების სინათლე + Convert to Grayscale ნაცრისფერ ტონებად გარდაქმნა + Sharpness სიმკვეთრე + Color Noise Reduction ფერის ხმაურის შემცირება + Luminance Smoothness სიკაშკაშის სიგლუვე + Make მწარმოებელი + Model მოდელი + Lens ობიექტივი + F F + ISO ISO + Focal Length35mm ფოკუსური მანძილი (35 მმ-ის ეკვივალენტი) + Aspect Ratio თანაფარდობა + Time დრო + Artist ავტორი + Enter description… შეიყვანეთ აღწერა… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard ინდუსტრიული სტანდარტი + Uncoated CMYK CMYK უცარცო ქაღალდისთვის + Newspaper გაზეთი + Working Spaces სამუშაო სივრცეები + Color Management Policies ფერების მართვის წესები + Detect Faces სახეების ამოცნობა + Localize Color Clusters ფერთა კლასტერების ლოკალიზება + Duplicated selection name მონიშნულის სახელი დუბლირებულია + Operation ოპერაცია + New channel only მხოლოდ ახალი არხი + Masked Areas ნიღბიანი არეები + Selected Areas მონიშნული არეები + Spot Color სპოტ-ფერი + Color indicates ფერი აღნიშნავს + Create Snapshot სნეპშოტის შექმნა + Delete Snapshot სნეპშოტის წაშლა + New Snapshot ახალი სნეპშოტი + Snapshot 1 სნეპშოტი 1 + Step Forward ნაბიჯით წინ + Step Backward ნაბიჯით უკან + Nonlinear History არაწრფივი ისტორია + Set New Snapshot as Default ახალი სნეპშოტის ნაგულისხმევად დაყენება + Automatically Create First Snapshot პირველი სნეპშოტის ავტომატურად შექმნა + Automatically Create New Snapshot When Saving შენახვისას ახალი სნეპშოტის ავტომატურად შექმნა + Show New Snapshot Dialog by Default ახალი სნეპშოტის დიალოგის ნაგულისხმევად ჩვენება + Clear History ისტორიის გასუფთავება + Panel Options… პანელის პარამეტრები… + View menu ხედის მენიუ + Keep view in sync ხედის სინქრონიზაცია + {zoom}% {zoom}% + Mouse Controls თაგვის მართვა + Initial Direction of Stroke მონასმის საწყისი მიმართულება + Direction of Stroke მონასმის მიმართულება + Humidity ტენიანობა + Auto Clean Brush ფუნჯის ავტომატური გაწმენდა + Spacing: ინტერვალი: + Brush Tip ფუნჯის წვერი + New Layer from Brush ახალი შრე ფუნჯიდან + New Brush ახალი ფუნჯი + New Tool Preset ხელსაწყოს ახალი პრესეტი + Auto-Leading ავტომატური სტრიქონთა დაშორება + Scale Horizontally ჰორიზონტალური მასშტაბირება + Scale Vertically ვერტიკალური მასშტაბირება + Baseline Shift საბაზისო ხაზის წანაცვლება + Mac LCD Mac LCD + Mac Sharp Mac მკვეთრი + Mac Crisp Mac ხასხასა + Mac Strong Mac ძლიერი + Mac Smooth Mac გლუვი + Superscript ზედა ინდექსი + Subscript ქვედა ინდექსი + Ligature ლიგატურა + Alternative ალტერნატიული + Roman რომაული + Bold Italic მუქი დახრილი + Ultra Light ულტრა ღია + Extra Light ძალიან ღია + Semi Bold ნახევრად მუქი + Extra Bold ძალიან მუქი + Ultra Bold ულტრა მუქი + Condensed შევიწროებული + First Line Indent პირველი სტრიქონის შეწევა + Hanging Punctuation ჩამოკიდებული პუნქტუაცია + Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Avoid Widows/Orphans ობოლი სტრიქონების თავიდან აცილება + Compose აწყობა + Adobe Single-line Composer Adobe-ის სტრიქონის მიხედვით აწყობა + Adobe Every-line Composer Adobe-ის აბზაცის მიხედვით აწყობა + Justification სწორება + Word Spacing სიტყვებს შორის ინტერვალი + Letter Spacing ასოებს შორის ინტერვალი + Glyph Scaling გლიფების მასშტაბირება + Auto-leading % ავტომატური სტრიქონთა დაშორება, % + Single Word Justification ერთი სიტყვის ორმხრივი სწორება + Alignment სწორება + Justify ორმხრივი სწორება + Glyph გლიფი + Alternates for Current Selection ალტერნატივები მიმდინარე მონიშნულისთვის + Digits ციფრები + Symbols სიმბოლოები + Punctuation პუნქტუაცია + Path: ტრაექტორია: + Path to Illustrator… ტრაექტორია Illustrator-ისთვის… + Make Work Path სამუშაო ტრაექტორიის შექმნა + Make Selection მონიშნულის შექმნა + Fill Path ტრაექტორიის შევსება + Stroke Path ტრაექტორიის კონტური + Clipping Path ჩაჭრის ტრაექტორია + New Path ახალი ტრაექტორია + Duplicate Path ტრაექტორიის დუბლირება + Delete Path ტრაექტორიის წაშლა + Auto-Hide ავტომატური დამალვა + W W + H H + X X + Y Y + Frame Sync კადრების სინქრონიზაცია + Quick Sync სწრაფი სინქრონიზაცია + Audio აუდიო + Frame Duration კადრის ხანგრძლივობა + Loop ციკლი + Split at Playhead გაყოფა დაკვრის მაჩვენებელთან + Flatten Frames Into Clips კადრების კლიპებად გაერთიანება + Flatten Frames Into Layers კადრების შრეებად გაერთიანება + Convert Frames > Video Layers კადრების გარდაქმნა > ვიდეოშრეები + New Video Layer ახალი ვიდეოშრე + New Video Group ახალი ვიდეოჯგუფი + Limit Playback დაკვრის შეზღუდვა + Enable Timeline Quick Config დროის ხაზის სწრაფი კონფიგურაციის ჩართვა + Set Start Timecode… საწყისი დროის კოდის დაყენება… + Timecode დროის კოდი + 30 fps 30 კადრი/წმ + 25 fps 25 კადრი/წმ + 24 fps 24 კადრი/წმ + 15 fps 15 კადრი/წმ + 10 fps 10 კადრი/წმ + Current Time მიმდინარე დრო + Out გამოსვლა + In შესვლა + Duration ხანგრძლივობა + Repeat გამეორება + Play Once ერთხელ დაკვრა + Info Options… ინფორმაციის პარამეტრები… + First Color Readout ფერის პირველი ანათვალი + Second Color Readout ფერის მეორე ანათვალი + Actual Color ფაქტობრივი ფერი + Grayscale Slider ნაცრისფერი ტონების სლაიდერი + Web Color ვებფერი + HSB Color HSB ფერი + Total Ink საღებავის ჯამი + Opacity Mask გაუმჭვირვალობის ნიღაბი + Ruler სახაზავი + Document Size დოკუმენტის ზომა + Timing დროის აღრიცხვა + Status Information მდგომარეობის ინფორმაცია + Saved Multichannel შენახული მრავალარხიანი + Mouse Coordinates თაგვის კოორდინატები + Ruler Units სახაზავის ერთეულები + Hide დამალვა + New Layer Comp შრეების ახალი კომპოზიცია + Duplicate Layer Comp შრეების კომპოზიციის დუბლირება + Rename Layer Comp შრეების კომპოზიციის გადარქმევა + Apply Layer Comp შრეების კომპოზიციის გამოყენება + Update Layer Comp შრეების კომპოზიციის განახლება + Restore last viewed comp ბოლოს ნანახი კომპოზიციის აღდგენა + Method მეთოდი + Select All Layers ყველა შრის მონიშვნა + Keep proportions პროპორციების შენარჩუნება + Interpolation ინტერპოლაცია + Author ავტორი + Caption წარწერა + Caption Credits წარწერის ავტორები + Keywords საკვანძო სიტყვები + Copyright Info URL საავტორო უფლებების ინფორმაციის URL + Copyright Status საავტორო უფლებების სტატუსი + Copyrighted დაცულია საავტორო უფლებით + Public Domain საზოგადოებრივი საკუთრება + Unknown უცნობი + Credit ავტორობა + Transmission Reference გადაცემის მითითება + Urgency სასწრაფოობა + High მაღალი + Low დაბალი + Category კატეგორია + Supplemental Categories დამატებითი კატეგორიები + Object Name / Title ობიექტის სახელი / სათაური + Date Created შექმნის თარიღი + City ქალაქი + Location მდებარეობა + Province / State რეგიონი / შტატი + Country ქვეყანა + Byline ავტორის ხელმოწერა + Byline Title ავტორის თანამდებობა + Phone ტელეფონი + Email ელფოსტა + URLs URL-ები + Last Modified ბოლო ცვლილება + EXIF EXIF + GPS GPS + Camera Data კამერის მონაცემები + Camera Model კამერის მოდელი + Camera Make კამერის მწარმოებელი + EXIF Version EXIF-ის ვერსია + Exposure Program ექსპოზიციის პროგრამა + Aperture დიაფრაგმა + Shutter Speed ჩამკეტის სიჩქარე + Metering Mode გაზომვის რეჟიმი + Light Source სინათლის წყარო + Flash განათება (Flash) + Lens Info ობიექტივის ინფორმაცია + ISO Speed ISO მგრძნობიარობა + Subject Distance მანძილი ობიექტამდე + DateTime Original ორიგინალის თარიღი და დრო + Create Date შექმნის თარიღი + Modify Date ცვლილების თარიღი + Software პროგრამა + Raw Data დაუმუშავებელი მონაცემები + Advanced დამატებითი + Import… იმპორტი… + PDF Presentation… PDF პრეზენტაცია… + Create Multiple Picture Packages რამდენიმე ფოტოპაკეტის შექმნა + Picture Package… ფოტოპაკეტი… + Web Photo Gallery… ვებფოტოგალერეა… + Choose… არჩევა… + Select Folder… საქაღალდის არჩევა… + Override Action "Save" Commands მოქმედების „შენახვის“ ბრძანებების უგულებელყოფა + File Naming ფაილების დასახელება + Compatibility თავსებადობა + Mac OS Mac OS + Unix Unix + Include ICC Profile ICC პროფილის ჩართვა + Error File შეცდომების ფაილი + Stop For Errors შეცდომისას შეჩერება + Log Errors To File შეცდომების ფაილში ჩაწერა + Errors შეცდომები + Settings პარამეტრები + Settings: პარამეტრები: + Working Space სამუშაო სივრცე + RGB: RGB: + CMYK: CMYK: + Gray: რუხი: + Spot: სპოტ-ფერი: + Advanced Controls დამატებითი მართვის ელემენტები + Desaturate Monitor Colors By მონიტორის ფერების გაუფერულება: + Blend RGB Colors Using Gamma 1.0 RGB ფერების შერევა გამით 1.0 + Profile Mismatches: Ask When Opening პროფილების შეუსაბამობა: გახსნისას შეკითხვა + Ask When Pasting ჩასმისას შეკითხვა + Missing Profiles: Ask When Opening აკლია პროფილები: გახსნისას შეკითხვა + Embed profiles პროფილების ჩაშენება + Ask Before Saving Saved Embedded შენახვამდე შეკითხვა ჩაშენებული პროფილის შესახებ + Coerce to sRGB sRGB-ზე იძულებით გადაყვანა + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web გრაფიკა და ვები + Minimalistic მინიმალისტური + Expand All Groups ყველა ჯგუფის ჩამოშლა + Photo (2 × 3 in) ფოტო (2 × 3 დუიმი) + Photo (4 × 6 in) ფოტო (4 × 6 დუიმი) + Photo (5 × 7 in) ფოტო (5 × 7 დუიმი) + Photo (8 × 10 in) ფოტო (8 × 10 დუიმი) + Recent Items ბოლოდროინდელი ელემენტები + Save Preset… პრესეტის შენახვა… + Delete Preset… პრესეტის წაშლა… + Preset Name: პრესეტის სახელი: + Width and Height სიგანე და სიმაღლე + Color Profile ფერის პროფილი + Units ერთეულები + Columns სვეტები + Create new count group ახალი დათვლის ჯგუფის შექმნა + Adjustment Layer მაკორექტირებელი შრე + Pencil Tool ფანქრის ხელსაწყო + Pixel პიქსელი + Adjustment კორექცია + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract გადაათრიეთ (ლასო) ან დაწკაპეთ წერტილებზე (მრავალკუთხა) · {add} დამატება · {sub} გამოკლება + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels გადაათრიეთ ჩამოჭრის ჩარჩო · შიგნით გადათრევით გადაადგილება · კიდეებით ზომის შეცვლა ({ratio} თანაფარდობა, {centre} ცენტრიდან) · Space — გადაადგილება ხაზვისას · ისრები — მცირე წანაცვლება · X — ორიენტაციის შეცვლა · {commit} — დადასტურება · Esc — გაუქმება + {mode} Color · {bits} bit {mode} ფერი · {bits} ბიტი + Drag to move the layer გადაათრიეთ შრის გადასაადგილებლად + Scrubby Zoom მასშტაბირება გადათრევით + Auto Erase ავტომატური წაშლა + Color Adaptation ფერის ადაპტაცია + Preserve Transparency გამჭვირვალობის შენარჩუნება + New Style… ახალი სტილი… + shortcut მალსახმობი + Go to Layer შრეზე გადასვლა + Saved შენახულია + After (X) შემდეგ (X) + Current მიმდინარე + Reference point location საყრდენი წერტილის მდებარეობა + Toggle reference point საყრდენი წერტილის ჩართვა/გამორთვა + Arc რკალი + Arch თაღი + Bulge ამობურცვა + Shell Lower ქვედა ნიჟარა + Shell Upper ზედა ნიჟარა + Flag დროშა + Fish თევზი + Wave ტალღა + Rise აწევა + Inflate გაბერვა + Twist გრეხა + Squeeze შეკუმშვა + Add Pins სამაგრების დამატება + Delete Pins სამაგრების წაშლა + Rotate Auto ავტომატური შემობრუნება + Rotate Fixed ფიქსირებული შემობრუნება + Create Plane სიბრტყის შექმნა + Edit Plane სიბრტყის რედაქტირება + Select Plane სიბრტყის მონიშვნა + Measure გაზომვა + Heal აღდგენა + Heal Off აღდგენა გამორთულია + Heal On აღდგენა ჩართულია + Heal Auto ავტომატური აღდგენა + Blend შერევა + Brush Size ფუნჯის ზომა + Heal Color აღდგენის ფერი + Mask ნიღაბი + Channel არხი + Rectangle მართკუთხედი + Result შედეგი + New Channel ახალი არხი + Save Selection მონიშნულის შენახვა + Load Selection მონიშნულის ჩატვირთვა + Duplicate დუბლირება + Fast სწრაფი + Accurate ზუსტი + Add to დამატება: + Subtract from გამოკლება: + Intersect with გადაკვეთა: + Capture Comp კომპოზიციის ჩაწერა + No Layer Comps შრეების კომპოზიციები არ არის + Adobe PDF Presets… Adobe PDF-ის პრესეტები… + 3D… 3D… + Technology Previews… ტექნოლოგიების წინასწარი ვერსიები… + Background from Layer ფონი შრიდან + Add a mask (from the selection; ⌥ inverts) ნიღბის დამატება (მონიშნულიდან; ⌥ შებრუნებს) + Add to selection (⇧) მონიშნულზე დამატება (⇧) + Airbrush: keeps painting while the pointer rests. აეროგრაფი: ხატვა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. + Click the first point or press ↵ to close · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ ↵-ს დასახურად · Esc — გაუქმება + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, ⌘/Ctrl+დაწკაპუნება ან Delete + Click to apply, ⌥-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, ⌥+დაწკაპუნება — ცვლილებების გასუფთავება, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to zoom in · ⌥-click to zoom out დაწკაპეთ გასადიდებლად · ⌥+დაწკაპუნება — დასაპატარავებლად + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · ↩ დასრულება · Esc გაუქმება + Commit Perspective Warp (↩) პერსპექტიული დეფორმაციის დადასტურება (↩) + Commit Puppet Warp (↩) მარიონეტული დეფორმაციის დადასტურება (↩) + Commit any current edits (⌘↩) მიმდინარე ცვლილებების დადასტურება (⌘↩) + Commit current crop operation (↵) მიმდინარე ჩამოჭრის დადასტურება (↵) + Commit transform (↩) გარდაქმნის დადასტურება (↩) + Commit warp (↩) დეფორმაციის დადასტურება (↩) + Create a new layer (⇧⌘N) ახალი შრის შექმნა (⇧⌘N) + Custom pattern მორგებული პატერნი + General Brushes ზოგადი ფუნჯები + Grid (⌘') ბადე (⌘') + Intersect with selection (⇧⌥) მონიშნულთან გადაკვეთა (⇧⌥) + New document… ⌘N ახალი დოკუმენტი… ⌘N + Open… ⌘O გახსნა… ⌘O + Rulers (⌘R) სახაზავები (⌘R) + Search commands (⌘K) ბრძანებების ძიება (⌘K) + Show Guides (⌘;) დამხმარე ხაზების ჩვენება (⌘;) + Subtract from selection (⌥) მონიშნულიდან გამოკლება (⌥) + Uses the foreground and background colors. იყენებს წინა პლანისა და ფონის ფერებს. + ↑↓ navigate ↵ run esc close ↑↓ ნავიგაცია ↵ გაშვება esc დახურვა + Adds grain to the soft edges of the brush tip. ფუნჯის წვერის რბილ კიდეებს მარცვლოვნებას მატებს. + Keeps the current texture when switching presets. პრესეტების გადართვისას მიმდინარე ტექსტურას ინარჩუნებს. + Watercolour look: lighter interior, darker rims. აკვარელის ეფექტი: ღია შიგნით, მუქი კიდეები. + Collapse group (⌥-click: all groups) ჯგუფის ჩაკეცვა (⌥+დაწკაპუნება: ყველა ჯგუფი) + Expand group (⌥-click: all groups) ჯგუფის გაშლა (⌥+დაწკაპუნება: ყველა ჯგუფი) + Hide the layer's effects (⌥-click: all layers) შრის ეფექტების დამალვა (⌥+დაწკაპუნება: ყველა შრე) + Show the layer's effects (⌥-click: all layers) შრის ეფექტების ჩვენება (⌥+დაწკაპუნება: ყველა შრე) + New from Clipboard ახალი გაცვლის ბუფერიდან + Free Transform a Copy ასლის თავისუფალი გარდაქმნა + Transform Again on a Copy ასლზე გარდაქმნის გამეორება + Select Layer Above ზედა შრის მონიშვნა + Select Layer Below ქვედა შრის მონიშვნა + Select Top Layer უზედაესი შრის მონიშვნა + Select Bottom Layer უქვედაესი შრის მონიშვნა + Add Layer Above to Selection ზედა შრის მონიშნულზე დამატება + Add Layer Below to Selection ქვედა შრის მონიშნულზე დამატება + 8 bit 8 ბიტი + Integer მთელი რიცხვი + 16 bit 16 ბიტი + 32 bit (float) 32 ბიტი (მცურავი) + Floating point მცურავი წერტილი + Use Global Light გლობალური განათების გამოყენება + Native file drag-and-drop is unavailable ფაილების სისტემური გადათრევა მიუწვდომელია + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. ფაილების სისტემური გადათრევა Wayland-ზე ჯერ მხარდაჭერილი არ არის. გამოიყენეთ ფაილი › გახსნა, ან დააკოპირეთ სურათი ფაილების მენეჯერში და ჩასვით {paste}-ით. + To drop files, start PhotoCraft under XWayland: `{command}` ფაილების ჩასაგდებად გაუშვით PhotoCraft XWayland-ში: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. ან დააყენეთ პარამეტრები › წარმადობა › Linux-ის ეკრანის სერვერი X11-ზე: მაშინ PhotoCraft ყოველთვის XWayland-ში გაეშვება. + Edit › Paste რედაქტირება › ჩასმა + Use File › Open, or paste a copied image with {paste}. გამოიყენეთ ფაილი › გახსნა, ან ჩასვით დაკოპირებული სურათი {paste}-ით. + Mixer Brush Tool შემრევი ფუნჯის ხელსაწყო + Color Replacement Tool ფერის ჩანაცვლების ხელსაწყო + Invalid {mode} profile {mode} პროფილი არასწორია + Unnamed {mode} profile უსახელო {mode} პროფილი + Show bounding box when dragging layer შრის გადათრევისას შემომსაზღვრელი ჩარჩოს ჩვენება + System title bar სისტემური სათაურის ზოლი + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) ლასოთი გაყინვა: გადათრევით არე იყინება, Alt+გადათრევით — დნება (L) + Rendering Mode რენდერის რეჟიმი + Automatic (recommended) ავტომატური (რეკომენდებული) + CPU / Compatibility CPU / თავსებადობა + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. ავტომატური რეჟიმი იყენებს GPU-აჩქარებას, როცა ის ხელმისაწვდომია, ხოლო შეცდომისას CPU-რენდერზე გადადის. + Switched to CPU rendering გადართულია CPU-რენდერზე + GPU acceleration could not continue. Your documents are unchanged. GPU-აჩქარების გაგრძელება ვერ მოხერხდა. თქვენი დოკუმენტები უცვლელია. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft იყენებს CPU-ზე მომუშავე გამოსახულების კომპოზიტორს. ფანჯარამ შეიძლება მაინც გამოიყენოს თქვენი ვიდეოადაპტერი. + Retry GPU requires a restart. Save your work first. GPU-ის ხელახლა ცდას გადატვირთვა სჭირდება. ჯერ შეინახეთ ნამუშევარი. + Keep Using CPU CPU-ის გამოყენების გაგრძელება + Retry GPU GPU-ის ხელახლა ცდა + GPU Backend GPU ბექენდი + Details დეტალები + Rendering mode რენდერის რეჟიმი + Shadow Clipping Warning (U) ჩრდილების მოკვეთის გაფრთხილება (U) + Highlight Clipping Warning (O) ღია ტონების მოკვეთის გაფრთხილება (O) + Show Lab Color Readouts Lab ფერის მნიშვნელობების ჩვენება + Show Vectorscope ვექტორსკოპის ჩვენება + Show Red at 3 o'clock წითლის ჩვენება 3 საათის მიმართულებით + Show Skin Tone Indicator კანის ტონის მაჩვენებლის ჩვენება + Tone Histogram ტონების ჰისტოგრამა + Show Selected Region მონიშნული რეგიონის ჩვენება +cameraRaw Light სინათლე + Camera Raw Filter ({layer}) Camera Raw ფილტრი ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights ნათელი ტონები +cameraRaw Darks მუქი ტონები +cameraRaw Blacks შავები +cameraRaw Amount რაოდენობა +cameraRaw Tint ტონირება +cameraRaw Masking ნიღბვა +cameraRaw Blending შერევა + Patch Tool საკერებლის ხელსაწყო + Patch: საკერებელი: + Lasso around an area, then drag the selection შემოხაზეთ არე ლასოთი, შემდეგ გადაათრიეთ მონიშნული + Now drag the selection onto another area ახლა გადაათრიეთ მონიშნული სხვა არეზე + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: ბადე: + Edit Type ტექსტის რედაქტირება +@id edit.stroke კონტური… +@id edit.transform.scale მასშტაბირება +cameraRaw Red წითელი +cameraRaw Orange ნარინჯისფერი +cameraRaw Yellow ყვითელი +cameraRaw Green მწვანე +cameraRaw Aqua ზღვისფერი +cameraRaw Blue ლურჯი +cameraRaw Purple იისფერი +cameraRaw Magenta მეწამული +cameraRaw Luminance სიკაშკაშე +cameraRaw Temperature ტემპერატურა +cameraRaw Color ფერი +cameraRaw Vibrance სიხალასე +cameraRaw Color Grading ფერის კორექცია (grading) +cameraRaw Global გლობალური +cameraRaw Hue ფერის ტონი +cameraRaw Luminance Detail სიკაშკაშის დეტალები +cameraRaw Color Detail ფერის დეტალები +cameraRaw Roughness უხეშობა +cameraRaw Whites თეთრები + No patterns პატერნები არ არის + Enable {name} {name}-ის ჩართვა + Hold Alt while drawing for straight segments სწორი მონაკვეთებისთვის ხაზვისას დააჭირეთ Alt-ს + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts გადაათრიეთ ან დაწკაპეთ მრავალკუთხედის წერტილებზე · სწორი მონაკვეთებისთვის ლასოს დროს დააჭირეთ Alt-ს · {add} დამატება · {sub} ხაზვამდე — გამოკლება + Extend გაფართოება + Structure: სტრუქტურა: + Content-Aware Move Tool შიგთავსის გათვალისწინებით გადაადგილების ხელსაწყო + TIFF Options TIFF-ის პარამეტრები + Targa Options Targa-ს პარამეტრები + 24 bits/pixel 24 ბიტი/პიქსელი + 32 bits/pixel 32 ბიტი/პიქსელი + Including layers will increase file size. შრეების ჩართვა ფაილის ზომას გაზრდის. + Discard Layers and Save a Copy შრეების უგულებელყოფა და ასლის შენახვა + Don't show again აღარ აჩვენო + Save failed: {error} შენახვა ვერ მოხერხდა: {error} + Save cancelled შენახვა გაუქმდა + +# Layer Style dialog: style presets and per-effect defaults + Style {name} saved სტილი {name} შენახულია + Make Default ნაგულისხმევად დაყენება + Reset to Default ნაგულისხმევზე დაბრუნება + Couldn't place {name}: {error} {name}-ის ჩასმა ვერ მოხერხდა: {error} + Full-resolution preview unavailable სრული გარჩევადობის გადახედვა მიუწვდომელია + Magnetic Lasso Tool მაგნიტური ლასოს ხელსაწყო + Frequency სიხშირე + Use tablet pressure to change pen width კალმის სისქის შეცვლა პლანშეტის დაწოლით + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About პროგრამის შესახებ + Contributors მონაწილეები + Models მოდელები + Sort სორტირება + Username მომხმარებლის სახელი + Display name საჩვენებელი სახელი + Real name ნამდვილი სახელი + Reverse the order რიგის შებრუნება + Grab bag სხვადასხვა + Table ცხრილი + Name (A–Z) სახელი (ა–ჰ) + Merged PRs გაერთიანებული PR-ები + Commits კომიტები + Lines added დამატებული სტრიქონები + Lines deleted წაშლილი სტრიქონები + Line delta სტრიქონების სხვაობა + Binary assets added დამატებული ბინარული ფაილები + Binary assets removed წაშლილი ბინარული ფაილები + First commit პირველი კომიტი + First პირველი + Last commit ბოლო კომიტი + Last ბოლო + Contributors: {contributors} · Commits: {commits} მონაწილეები: {contributors} · კომიტები: {commits} + No contributor data was built into this copy. ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული. + No model credits were built into this copy. ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული. + Company კომპანია + Version ვერსია + % of all commits ყველა კომიტის % + Lines +/− სტრიქონები +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, კომიტი: {commits}, სტრიქონი: +{added} / −{deleted} (Δ {delta}), ბინარული ფაილი: +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch ახალი ფერის ნიმუშის შექმნა + New Swatch… ფერის ახალი ნიმუში… + New Swatch Group… ფერის ნიმუშების ახალი ჯგუფი… + Import Swatches… ფერის ნიმუშების იმპორტი… + Replace Swatches… ფერის ნიმუშების ჩანაცვლება… + Export Swatches… ფერის ნიმუშების ექსპორტი… + Export Swatches for Exchange… ფერის ნიმუშების ექსპორტი გასაცვლელად… + Reset Swatches ფერის ნიმუშების ჩამოყრა + Add to Swatches ფერის ნიმუშებში დამატება +# Layer Style > Blending Options (Advanced Blending) + Blend If: შერევა, თუ: + Channels: არხები: + Knockout ამოგდება + Apply At Canvas Bounds ტილოს საზღვრებზე გამოყენება + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) ფერის ამრჩევი (ტექსტის ფერი) + Color Picker ({name}) ფერის ამრჩევი ({name}) + Color Picker (Fill Color) ფერის ამრჩევი (შევსების ფერი) + Fill color შევსების ფერი + Color Picker (Stroke Color) ფერის ამრჩევი (კონტურის ფერი) + Stroke color კონტურის ფერი + Pick screen color ეკრანის ფერის აღება + Sample a pixel anywhere on the screen პიქსელის ნიმუშის აღება ეკრანის ნებისმიერი ადგილიდან + Click to pick · Esc to cancel დაწკაპეთ ასაღებად · Esc — გაუქმება + Pick a screen pixel or press Esc to cancel აიღეთ ეკრანის პიქსელი ან დააჭირეთ Esc-ს გასაუქმებლად + Scroll to zoom გადახვევით მასშტაბირება + Show/Hide All Panels ყველა პანელის ჩვენება/დამალვა + Show/Hide Panels პანელების ჩვენება/დამალვა + Could not open გახსნა ვერ მოხერხდა + {path}: the path is not valid Unicode; rename the file and open it again. {path}: გზა არ არის სწორი Unicode; გადაარქვით ფაილს სახელი და ხელახლა გახსენით. + +# Fill dialog, layer style contours and Image Size + Contents: შიგთავსი: + Custom Pattern: მორგებული პატერნი: + Foreground Color წინა პლანის ფერი + Color… ფერი… + 50% Gray 50% ნაცრისფერი + {size} (was {old}) {size} (იყო {old}) + Cone კონუსი + Cone (Inverted) კონუსი (შებრუნებული) + Domed გუმბათოვანი + Domed (Inverted) გუმბათოვანი (შებრუნებული) + Diagonal (Descending) დიაგონალური (დაღმავალი) + Contour Editor კონტურის რედაქტორი + Anti-aliased დაგლუვებული + Corner კუთხე + Gloss Contour ბზინვარების კონტური + Contour კონტური + Cone - Inverted კონუსი - შებრუნებული + Cove - Deep ღარი - ღრმა + Cove - Shallow ღარი - ზედაპირული + Gaussian გაუსის + Half Round ნახევარწრიული + Ring რგოლი + Ring - Double რგოლი - ორმაგი + Rolling Slope - Descending ტალღოვანი ფერდობი - დაღმავალი + Rounded Steps მომრგვალებული საფეხურები + Sawtooth 1 ხერხკბილა 1 + + Red Eye Tool წითელი თვალების ხელსაწყო + Pupil Size გუგის ზომა + Darken Amount ჩამუქების რაოდენობა + Click a red pupil to neutralize it დაწკაპეთ წითელ გუგაზე მის გასანეიტრალებლად + open source ღია კოდი + …and {n} more …და კიდევ {n} + + Eyedroppers: პიპეტები: + Set Black Point შავი წერტილის დაყენება + Set Neutral Gray Point ნეიტრალური ნაცრისფერი წერტილის დაყენება + Set White Point თეთრი წერტილის დაყენება + Unavailable in this color mode მიუწვდომელია ამ ფერის რეჟიმში + Selection Preview: მონიშნულის გადახედვა: + Black Matte შავი ფონი + White Matte თეთრი ფონი + +# Layer colour labels. + Seafoam ზღვის ქაფი + Indigo ინდიგო + Fuchsia ფუქსია + Violet იისფერი + Large document: redrawing on the CPU დიდი დოკუმენტი: გადახატვა CPU-ზე + This redraw took {seconds} s on the CPU. ამ გადახატვას CPU-ზე {seconds} წმ დასჭირდა. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. ცვლილებები, რომლებიც მთელ დოკუმენტს ცვლის, მაგალითად შრის შერევის რეჟიმი, გაუმჭვირვალობა ან ხილვადობა, მთელ დოკუმენტს ხელახლა ხატავს. + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. მისი შრეები GPU-ის მეხსიერების ლიმიტში არ ეტევა. პარამეტრები › წარმადობა › მეხსიერების გამოყენების გაზრდა ლიმიტს ზრდის, ამ კომპიუტერის მეხსიერების მეოთხედამდე. + The GPU compositor wasn't used: {reason} GPU-კომპოზიტორი არ იქნა გამოყენებული: {reason} +fontWeight Thin თხელი +fontWeight ExtraLight ძალიან ღია +fontWeight Light ღია +fontWeight Regular ჩვეულებრივი +fontWeight Medium საშუალო +fontWeight SemiBold ნახევრად მუქი +fontWeight Bold მუქი +fontWeight ExtraBold ძალიან მუქი +fontWeight Black შავი +fontWeight Italic დახრილი + +# Pattern Stamp tool + Pattern Stamp Tool პატერნის შტამპის ხელსაწყო + Impressionist იმპრესიონისტული + Rotate View Tool ხედის შემობრუნების ხელსაწყო + Reset View ხედის ჩამოყრა + Drag around the centre to rotate the view · Shift constrains to 15° გადაათრიეთ ცენტრის გარშემო ხედის შესაბრუნებლად · Shift — 15°-იანი ბიჯით + +# Painting symmetry presets. + Dual Axis ორი ღერძი + Diagonal დიაგონალური + Transform Symmetry სიმეტრიის გარდაქმნა + +# Eyedropper Sample Size and Sample (#1649). + Point Sample წერტილოვანი ნიმუში + 3 by 3 Average საშუალო 3×3 + 5 by 5 Average საშუალო 5×5 + 11 by 11 Average საშუალო 11×11 + 31 by 31 Average საშუალო 31×31 + 51 by 51 Average საშუალო 51×51 + 101 by 101 Average საშუალო 101×101 + Sample Size: ნიმუშის ზომა: + All Layers No Adjustments ყველა შრე, კორექციის გარეშე + Current & Below No Adjustments მიმდინარე და ქვედა, კორექციის გარეშე + Show Sampling Ring ნიმუშის აღების რგოლის ჩვენება + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour ბეჭდვისთვის ფერთა დიაპაზონს გარეთაა: დაწკაპეთ უახლოესი დასაბეჭდი ფერისთვის + Not a web colour: click for the nearest web colour ვებფერი არ არის: დაწკაპეთ უახლოესი ვებფერისთვის + Color to Alpha… ფერიდან ალფაში… + Transparency Threshold გამჭვირვალობის ზღვარი + Opacity Threshold გაუმჭვირვალობის ზღვარი + PhotoCraft — Screen Color Picker PhotoCraft — ეკრანის ფერის ამრჩევი + + Rasterize svg groups deeper than SVG ჯგუფების რასტრირება სიღრმიდან + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 ამ ზღვარს გამორთავს; 100 დონეზე ღრმად ჩადგმული SVG ჯგუფები ყოველთვის რასტრირდება. + Rule of Thirds მესამედების წესი + Triangle სამკუთხედი + Golden Ratio ოქროს კვეთა + Golden Spiral ოქროს სპირალი + Auto Show Overlay გადაფარვის ავტომატური ჩვენება + Always Show Overlay გადაფარვის ყოველთვის ჩვენება + Never Show Overlay გადაფარვის არასოდეს ჩვენება + Cycle Overlay გადაფარვების გადართვა + Cycle Orientation ორიენტაციის გადართვა + Set the overlay options for the Crop tool ჩამოჭრის ხელსაწყოს გადაფარვის პარამეტრები + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-დაწკაპუნება სამუშაო მაგიდამ მიიღო, რომელიც Alt-გადათრევით ფანჯრებს ამოძრავებს. ფანჯრის გადაადგილების კლავიშად Super დააყენეთ, ან Alt დააფიქსირეთ: ფანჯარა › მოდიფიკატორი კლავიშები. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) ფერის ამრჩევი (შრის სტილის ფერი) + Auto Hide Notifications შეტყობინებების ავტომატური დამალვა + Notification Duration (seconds) შეტყობინების ხანგრძლივობა (წამი) + Appearance Mode იერსახის რეჟიმი + Dark Theme ბნელი თემა + Light Theme ღია თემა + Pro Medium Pro საშუალო + Studio Light Studio ღია + Next Appearance Mode შემდეგი იერსახის რეჟიმი + Sync with system სისტემასთან სინქრონიზაცია + Active აქტიური + Color Picker (Solid Color) ფერის ამრჩევი (მყარი ფერი) + Straighten the image by drawing a line on it გამოსახულების გასწორება მასზე ხაზის გავლებით + Set additional Crop options ჩამოჭრის დამატებითი პარამეტრები + Show Cropped Area ჩამოჭრილი არის ჩვენება + Enable Crop Shield ჩამოჭრის ფარის ჩართვა + Match Canvas ტილოს მიხედვით + Auto Adjust Opacity გაუმჭვირვალობის ავტომატური მორგება + Crop Shield color ჩამოჭრის ფარის ფერი + Color Picker (Crop Shield Color) ფერის ამრჩევი (ჩამოჭრის ფარის ფერი) + From Center ცენტრიდან + Link radii რადიუსების დაკავშირება + Star Ratio: ვარსკვლავის თანაფარდობა: + Create Rectangle მართკუთხედის შექმნა + Create Ellipse ელიფსის შექმნა + Create Triangle სამკუთხედის შექმნა + Create Polygon მრავალკუთხედის შექმნა + Pressure curve დაწოლის მრუდი + Click to add a point, right-click to remove დაწკაპეთ წერტილის დასამატებლად, მარჯვენა დაწკაპუნებით — წასაშლელად + Some brush presets could not be loaded ფუნჯის ზოგიერთი პრესეტი ვერ ჩაიტვირთა + Retry saving presets პრესეტების შენახვის ხელახლა ცდა + Brush preset changes are not saved: {error} ფუნჯის პრესეტების ცვლილებები არ ინახება: {error} + Keep this tab open to retain your brushes. ფუნჯების შესანარჩუნებლად ეს ჩანართი ღია დატოვეთ. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. ბრაუზერის საცავი მიუწვდომელია; ფუნჯები მხოლოდ ამ სეანსში შეინახება. მათ შესანარჩუნებლად ეს ჩანართი ღია დატოვეთ. + Saving brush presets… Keep this tab open until saving finishes. ფუნჯის პრესეტები ინახება… ეს ჩანართი ღია დატოვეთ შენახვის დასრულებამდე. + Reflect არეკვლა + Glow ნათება + Negation უარყოფა + XOR XOR + Color Burn (Paint.NET) ფერით ჩამუქება (Paint.NET) + Color Dodge (Paint.NET) ფერით გაღიავება (Paint.NET) + Stroke Options კონტურის პარამეტრები + Solid უწყვეტი + Dashed ტირეებიანი + Dotted პუნქტირი + Caps ბოლოები + Butt / Flat ბლაგვი / ბრტყელი + Corners კუთხეები + Miter მახვილი + Round მომრგვალებული + Bevel წაკვეთილი + Miter limit მახვილის ზღვარი + Dashed line ტირეებიანი ხაზი + Dash ტირე + Gap შუალედი + Dash / gap lengths × stroke width ტირის / შუალედის სიგრძეები × კონტურის სისქე + Add pair წყვილის დამატება + Remove pair წყვილის წაშლა + Dash offset ტირის წანაცვლება + Open paths use centered strokes ღია ტრაექტორიების კონტური ცენტრირებულია + Use finite lengths and a positive pattern total გამოიყენეთ სასრული სიგრძეები და დადებითი ჯამური ნიმუში + No subject found — try Object Selection or the Lasso ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის მონიშვნა ან ლასო + Quick Selection found nothing there სწრაფმა მონიშვნამ იქ ვერაფერი იპოვა + Object Selection found nothing there — try a tighter rectangle ობიექტის მონიშვნამ იქ ვერაფერი იპოვა — სცადეთ უფრო ვიწრო მართკუთხედი + No subject found — try a rectangle around the object ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის გარშემო მართკუთხედი + Click the first point or press {key} to close · {del} removes a point · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ {key}-ს დასახურად · {del} წერტილს შლის · Esc აუქმებს + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close დაწკაპეთ, შემდეგ იმოძრავეთ კიდის გასწვრივ · დაწკაპეთ წერტილის დასამაგრებლად · {alt}-დაწკაპუნება — სწორი მონაკვეთი · ორმაგი დაწკაპუნება — დახურვა + Follows only edges this close to the pointer ([ and ] change it) მიჰყვება მხოლოდ მაჩვენებელთან ამდენად ახლოს მყოფ კიდეებს ([ და ] ცვლის) + Higher values follow only edges that contrast sharply with their surroundings მაღალი მნიშვნელობები მხოლოდ გარემოსთან მკვეთრად კონტრასტულ კიდეებს მიჰყვება + How often fastening points are placed by themselves რა სიხშირით ისმება დამაგრების წერტილები ავტომატურად + Appearance Settings… იერსახის პარამეტრები… + Add to favorites რჩეულებში დამატება + Remove from favorites რჩეულებიდან ამოშლა +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make მწარმოებელი +cameraData Model მოდელი +cameraData Date Taken გადაღების თარიღი +cameraData Exposure ექსპოზიცია +cameraData F-Stop დიაფრაგმა +cameraData ISO Speed ISO მგრძნობიარობა +cameraData Focal Length ფოკუსური მანძილი +cameraData Focal Length (35 mm) ფოკუსური მანძილი (35 მმ) +cameraData Lens ობიექტივი +# Shared panel docking + Activate Panel პანელის გააქტიურება + Change Panel Group პანელების ჯგუფის შეცვლა + Change Panel Layout პანელების განლაგების შეცვლა + Close Panel პანელის დახურვა + Dock Panel პანელის მიმაგრება + Float Panel მცურავი პანელი + Move Panel პანელის გადაადგილება + Group with ერთად დაჯგუფება + Panel menu პანელის მენიუ + Resize panel window პანელის ფანჯრის ზომის შეცვლა + Resize panels პანელების ზომის შეცვლა + Use Classic Mode კლასიკური რეჟიმის გამოყენება + Auto Center Preview გადახედვის ავტომატური ცენტრირება + Next Document შემდეგი დოკუმენტი + Previous Document წინა დოკუმენტი + W x H x Resolution სიგ. x სიმ. x გარჩევადობა + Front Image წინა გამოსახულება + Frame Tool ჩარჩოს ხელსაწყო + Create a new rectangular frame ახალი მართკუთხა ჩარჩოს შექმნა + Create a new elliptical frame ახალი ელიფსური ჩარჩოს შექმნა + Include in playback დაკვრაში ჩართვა + Perspective Crop Tool პერსპექტიული ჩამოჭრის ხელსაწყო + Show Grid ბადის ჩვენება + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels გადაათრიეთ ჩარჩო ან დაწკაპეთ ოთხ კუთხეზე · კუთხეები დახრილი ფიგურის კიდეებზე გადაათრიეთ · {commit} ადასტურებს · Esc აუქმებს + Artboard Tool სამუშაო დაფის ხელსაწყო + Make Portrait ვერტიკალურად + Make Landscape ჰორიზონტალურად + Add New Artboard ახალი სამუშაო დაფის დამატება + Freeform Pen Tool თავისუფალი ფორმის კალმის ხელსაწყო + Curvature Pen Tool მრუდის კალმის ხელსაწყო + Curve Fit: მრუდის მორგება: + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish დაწკაპუნება: გლუვი წერტილი · ორმაგი დაწკაპუნება: კუთხე · წერტილის გადათრევა: გადაადგილება · პირველ წერტილზე დაწკაპუნება: დახურვა · {key} დასრულება + New Crop Preset… ჩამოჭრის ახალი პრესეტი… + Delete Crop Preset… ჩამოჭრის პრესეტის წაშლა… + Select the crop presets to delete: აირჩიეთ წასაშლელი ჩამოჭრის პრესეტები: + Select the crop presets to delete აირჩიეთ წასაშლელი ჩამოჭრის პრესეტები + Enter a name for the crop preset შეიყვანეთ ჩამოჭრის პრესეტის სახელი + There is no crop size or ratio to save შესანახი ჩამოჭრის ზომა ან თანაფარდობა არ არის + Scale: მასშტაბი: + Snap to Origin სათავესთან მიბმა + Search settings… პარამეტრების ძიება… + No matching settings შესაბამისი პარამეტრები არ არის + Loading fonts… შრიფტები იტვირთება… +# Select and Mask view modes (#1754) + Onion Skin ხახვის ფენები + Marching Ants მოსიარულე ჭიანჭველები + On Black შავ ფონზე + On White თეთრ ფონზე + On Layers შრეებზე + Show Edge კიდის ჩვენება + Show Original ორიგინალის ჩვენება + F cycles the views; hold X to see the image F ხედებს ცვლის; X-ის დაჭერით გამოსახულება გამოჩნდება +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) კიდის დაზუსტების ფუნჯი (R) + Expand Detection Area აღმოჩენის არის გაფართოება + Restore Original Edge ორიგინალი კიდის აღდგენა + Paint over hair or fur on the image; Alt paints the other mode გამოსახულებაზე თმა ან ბეწვი გადაღებეთ; Alt მეორე რეჟიმით ხატავს + Clear Brush Strokes ფუნჯის მონასმების გასუფთავება + Show rulers სახაზავების ჩვენება + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove დაწკაპეთ განსათავსებლად · Alt-დაწკაპუნება ან ტილოს გარეთ გადათრევა — წაშლა + No document open დოკუმენტი გახსნილი არ არის + {n} of {max} sample points სინჯის წერტილი: {n} / {max} +layerLabel No Color ფერის გარეშე + Preferences… პარამეტრები… + Actual Pixels რეალური პიქსელები + Clipping Mask მომჭრელი ნიღაბი + Tool Options ხელსაწყოების პარამეტრები + Color Picker ფერის ამრჩევი + Color Swatches ფერის ნიმუშები + Linear Dodge წრფივი გაღიავება + Preview Image გამოსახულების გადახედვა + Save As შენახვა როგორც + Remove All ყველას წაშლა + Unlock განბლოკვა + Enable ჩართვა + Disable გამორთვა + On ჩართული + Clear Selection მონიშნულის გასუფთავება + Invert Selection მონიშვნის შებრუნება + Select Inverse მონიშვნის შებრუნება + Contract შევიწროება + Sample Size ნიმუშის ზომა + Strength სიძლიერე + Gamma გამა + Input შემავალი + Output გამომავალი + Input Levels შემავალი დონეები + Output Levels გამომავალი დონეები + Layer Styles შრის სტილები + Blending Mode შერევის რეჟიმი + Fill Color შევსების ფერი + Stroke Color კონტურის ფერი + Stroke Width კონტურის სისქე + Shadow ჩრდილი + Adjustment Layers მაკორექტირებელი შრეები + Text Layer ტექსტური შრე + Documents დოკუმენტები + File Name ფაილის სახელი + File Format ფაილის ფორმატი + Compression შეკუმშვა + Artboards სამუშაო დაფები + Snap to Grid ბადეზე მიბმა + Full Screen მთელ ეკრანზე + Fullscreen მთელ ეკრანზე + Workspaces სამუშაო სივრცეები + Show All ყველას ჩვენება + Hidden დამალული + New Layer ახალი შრე + Duplicate Layer შრის დუბლირება + Ungroup დაჯგუფების მოხსნა + Lock Transparent Pixels გამჭვირვალე პიქსელების ჩაკეტვა + Lock Image Pixels გამოსახულების პიქსელების ჩაკეტვა + Lock Position პოზიციის ჩაკეტვა + Lock All ყველაფრის ჩაკეტვა + Show Rulers სახაზავების ჩვენება + Guide Layout… დამხმარე ხაზების განლაგება… + Page Setup… გვერდის პარამეტრები… + Reset Preferences პარამეტრების ჩამოყრა + Documentation დოკუმენტაცია + Check for Updates განახლებების შემოწმება + Loading… იტვირთება… + Processing… მუშავდება… + Saving… ინახება… + Exporting… ექსპორტირდება… + Please wait… გთხოვთ, მოიცადოთ… + No selection მონიშნული არაფერია + No layers შრეები არ არის + No recent files ბოლოს გახსნილი ფაილები არ არის + Unsupported file format ფაილის ფორმატი მხარდაჭერილი არ არის + Failed to open file ფაილის გახსნა ვერ მოხერხდა + Failed to save file ფაილის შენახვა ვერ მოხერხდა + Operation canceled ოპერაცია გაუქმდა + Changes have not been saved. ცვლილებები შენახული არ არის. + Are you sure? დარწმუნებული ხართ? + Discard Changes ცვლილებების უარყოფა + Recent Files ბოლოს გახსნილი ფაილები + Open File ფაილის გახსნა + Save File ფაილის შენახვა + Save Changes ცვლილებების შენახვა + Rendering… რენდერდება… + Importing… იმპორტირდება… + Ready მზადაა + Failed ვერ მოხერხდა + Warning გაფრთხილება + Information ინფორმაცია + Retry ხელახლა ცდა + Continue გაგრძელება + Skip გამოტოვება + Resume განახლება + Reset All ყველაფრის ჩამოყრა + Defaults ნაგულისხმევი + Apply to All ყველაზე გამოყენება + Confirm დადასტურება + Finish დასრულება + Back უკან + Next შემდეგი + Previous წინა + More მეტი + Less ნაკლები + Manual ხელით + Disabled გამორთული + Locked ჩაკეტილი + Unlocked განბლოკილი + Delete… წაშლა… + Create… შექმნა… + Edit… რედაქტირება… + Change… შეცვლა… + Transformation გარდაქმნა + Scaling მასშტაბირება + Canvas Size ტილოს ზომა + Percentage პროცენტული + Degrees გრადუსი + Middle შუა + Diameter დიამეტრი + Intensity ინტენსივობა + Sensitivity მგრძნობელობა + Softness სირბილე + Feather Radius კიდეების შერბილების რადიუსი + Library ბიბლიოთეკა + Layer Properties შრის თვისებები + Groups ჯგუფები + Masks ნიღბები + Add Mask ნიღბის დამატება + Delete Mask ნიღბის წაშლა + Invert Mask ნიღბის ინვერსია + Enable Mask ნიღბის ჩართვა + Disable Mask ნიღბის გამორთვა + Apply Mask ნიღბის გამოყენება + Import Settings პარამეტრების იმპორტი + Export Settings პარამეტრების ექსპორტი + Shortcut მალსახმობი + Shortcuts მალსახმობები + Keyboard კლავიატურა + Mouse მაუსი + Tablet პლანშეტი + Display ეკრანი + Panel პანელი + Hide Grid ბადის დამალვა + Fit to Window ფანჯარაში ჩატევა + Live Preview ცოცხალი გადახედვა + Before მანამდე + After შემდეგ + Compare შედარება + File Size ფაილის ზომა + Filename ფაილის სახელი + Folder საქაღალდე + Open Folder საქაღალდის გახსნა + Choose Folder საქაღალდის არჩევა + Browse არჩევა + Find ძებნა + Replace ჩანაცვლება + Date თარიღი + Learn More დაწვრილებით + Report a Bug შეცდომის შეტყობინება + Updates განახლებები + Update განახლება + Restart Required საჭიროა გადატვირთვა + Restart გადატვირთვა + Quit გასვლა + Quit PhotoCraft PhotoCraft-იდან გასვლა + Close Window ფანჯრის დახურვა + New Window ახალი ფანჯარა + Open Image გამოსახულების გახსნა + Save Image გამოსახულების შენახვა + Export Image გამოსახულების ექსპორტი + Import Image გამოსახულების იმპორტი + Export As ექსპორტი სხვა ფორმატით + Export to ექსპორტი ფორმატში + Export Options ექსპორტის პარამეტრები + Import Options იმპორტის პარამეტრები + No Selection მონიშნული არაფერია + No Document დოკუმენტი არ არის + No Image გამოსახულება არ არის + No Layers შრეები არ არის + No Results შედეგები არ არის + No Results Found შედეგები ვერ მოიძებნა + Nothing to Undo დასაბრუნებელი არაფერია + Nothing to Redo ხელახლა შესასრულებელი არაფერია + Changes Saved ცვლილებები შენახულია + Unsaved Changes შეუნახავი ცვლილებები + Save before closing? შევინახო დახურვამდე? + Are you sure you want to quit? ნამდვილად გსურთ გასვლა? + The file could not be opened. ფაილის გახსნა ვერ მოხერხდა. + The file could not be saved. ფაილის შენახვა ვერ მოხერხდა. + Unsupported format ფორმატი მხარდაჭერილი არ არის + Out of memory მეხსიერება არ კმარა + Permission denied წვდომა აკრძალულია + File not found ფაილი ვერ მოიძებნა + Operation failed ოპერაცია ვერ შესრულდა + Operation completed ოპერაცია დასრულდა + Operation cancelled ოპერაცია გაუქმდა + Sharpen Amount გამკვეთრების ოდენობა + Sharpen Radius გამკვეთრების რადიუსი + Sharpen Detail გამკვეთრების დეტალი + Sharpen Masking გამკვეთრების ნიღბვა + Noise Luminance ხმაურის სიკაშკაშე + Noise Luminance Detail ხმაურის სიკაშკაშის დეტალი + Noise Color ხმაურის ფერი + Noise Color Detail ხმაურის ფერის დეტალი + Grain Amount მარცვლოვნების ოდენობა + Grain Size მარცვლის ზომა + Grain Roughness მარცვლის უხეშობა + Vignette Roundness ვინიეტის სიმრგვალე + Vignette Feather ვინიეტის კიდეების შერბილება + Vignette Highlights ვინიეტის ღია ტონები + Vignette Style ვინიეტის სტილი + Planes სიბრტყეები + Plane სიბრტყე + Shadow Amount ჩრდილის ოდენობა + Shadow Tone ჩრდილის ტონი + Shadow Radius ჩრდილის რადიუსი + Highlight Amount ღია ტონების ოდენობა + Highlight Tone ღია ტონების ტონი + Highlight Radius ღია ტონების რადიუსი + Midtone შუალედური ტონი + Black Clip შავის მოკვეთა + White Clip თეთრის მოკვეთა + Fuzziness გაბნეულობა + Source Layer წყარო შრე + Neutralize ნეიტრალიზება + Use Selection In Source წყაროში მონიშნულის გამოყენება + Use Selection In Target სამიზნეში მონიშნულის გამოყენება + Flame Type ალის ტიპი + Randomize Length სიგრძის შემთხვევითობა + Interval ინტერვალი + Adjust Interval For Loops ინტერვალის მორგება ციკლებისთვის + Use Custom Color საკუთარი ფერის გამოყენება + Flame Lines ალის ხაზები + Flame Bottom Alignment ალის ქვედა სწორება + Flame Style ალის სტილი + Flame Shape ალის ფორმა + Randomize Shapes ფორმების შემთხვევითობა + Frame ჩარჩო + Arrangement განლაგება + Thickness სისქე + Vine Color ვაზის ფერი + Flower Color ყვავილის ფერი + Leaf Color ფოთლის ფერი + Base Tree Type ხის ძირითადი ტიპი + Light Direction სინათლის მიმართულება + Leaves Amount ფოთლების რაოდენობა + Leaves Size ფოთლების ზომა + Branches Height ტოტების სიმაღლე + Branches Thickness ტოტების სისქე + Default Leaves ნაგულისხმევი ფოთლები + Leaves Color ფოთლების ფერი + Custom Branch Color ტოტების საკუთარი ფერი + Branches Color ტოტების ფერი + Flat Shading ბრტყელი დაჩრდილვა + Date and Time თარიღი და დრო + Scale Units მასშტაბის ერთეულები + Scale Factor მასშტაბის კოეფიციენტი + Area ფართობი + Perimeter პერიმეტრი + Circularity წრიულობა + Gray Value (Minimum) რუხი მნიშვნელობა (მინიმუმი) + Gray Value (Maximum) რუხი მნიშვნელობა (მაქსიმუმი) + Gray Value (Mean) რუხი მნიშვნელობა (საშუალო) + Gray Value (Median) რუხი მნიშვნელობა (მედიანა) + Integrated Density ინტეგრალური სიმკვრივე + Length სიგრძე + In Black შემავალი შავი + In White შემავალი თეთრი + Out Black გამომავალი შავი + Out White გამომავალი თეთრი + Tint Color ტონირების ფერი + Stops გადასვლის წერტილები + Center X ცენტრი X + Center Y ცენტრი Y + Distribution განაწილება + Monochromatic მონოქრომული + Cell Size უჯრის ზომა + Generators გენერატორები + Wavelength Min ტალღის სიგრძე მინ. + Wavelength Max ტალღის სიგრძე მაქს. + Amplitude Min ამპლიტუდა მინ. + Amplitude Max ამპლიტუდა მაქს. + Undefined Areas განუსაზღვრელი არეები + Preserve შენარჩუნება + Max Radius მაქს. რადიუსი + Depth Mode სიღრმის რეჟიმი + Solid Front Faces უწყვეტი წინა წახნაგები + Mask Incomplete Blocks არასრული ბლოკების დაფარვა + Stylization სტილიზაცია + Cleanliness სისუფთავე + Bristle Detail ჯაგრის დეტალი + Lighting განათება + Shine ბზინვარება + Max Offset მაქს. წანაცვლება + Map Document რუკის დოკუმენტი + Map Path რუკის ფაილი + Map Layer რუკის შრე + Ridges ქედები + Light Type სინათლის ტიპი + Light X სინათლე X + Light Y სინათლე Y + Light Z სინათლე Z + Target X სამიზნე X + Target Y სამიზნე Y + Hotspot ცხელი წერტილი + Elevation სიმაღლე + Ambient გარემოს განათება + Warmth სითბო + Gloss სიპრიალე + Metallic მეტალიკური + Ambience გარემო + White Is High თეთრი მაღალია + Reduce Color Noise ფერადი ხმაურის შემცირება + Sharpen Details დეტალების გამკვეთრება + Remove Jpeg Artifact JPEG არტეფაქტის მოცილება + Blade Curvature ფრთების სიმრუდე + Depth Map სიღრმის რუკა + Focal Distance ფოკუსური მანძილი + Focus ფოკუსი + Transition გადასვლა + Radius X რადიუსი X + Radius Y რადიუსი Y + Pins სამაგრები + Blur Angle გაბუნდოვნების კუთხე + Speed სიჩქარე + Taper წაწვეტება + Start X დასაწყისი X + Start Y დასაწყისი Y + End X დასასრული X + End Y დასასრული Y + Kernel ბირთვი + Input Mode შეყვანის რეჟიმი + Row Order სტრიქონების რიგი + Eliminate აღმოფხვრა + Create By შექმნის ხერხი + List სია + Pencil Width ფანქრის სისქე + Stroke Pressure შტრიხის დაწოლა + Paper Brightness ქაღალდის სიკაშკაშე + Number Of Levels დონეების რაოდენობა + Edge Simplicity კიდის სიმარტივე + Edge Fidelity კიდის სიზუსტე + Brush Detail ფუნჯის დეტალი + Highlight Area ნათელი არე + Glow Size ნათების ზომა + Glow Brightness ნათების სიკაშკაშე + Brush Type ფუნჯის ტიპი + Stroke Size შტრიხის ზომა + Stroke Detail შტრიხის დეტალი + Highlight Strength ღია ტონების სიძლიერე + Smoothness სიგლუვე + Edge Thickness კიდის სისქე + Edge Intensity კიდის ინტენსივობა + Posterization პოსტერიზაცია + Stroke Length შტრიხის სიგრძე + Relief რელიეფი + Definition სიცხადე + Texture Coverage ტექსტურის დაფარვა + Shadow Intensity ჩრდილის ინტენსივობა + Edge Width კიდის სიგანე + Edge Brightness კიდის სიკაშკაშე + Direction Balance მიმართულების ბალანსი + Black Intensity შავის ინტენსივობა + White Intensity თეთრის ინტენსივობა + Dark Intensity მუქის ინტენსივობა + Custom Pixel Aspect Ratio პიქსელის საკუთარი თანაფარდობა + 32-bit Preview Options 32-ბიტიანი გადახედვის პარამეტრები + New Guide ახალი დამხმარე ხაზი + Join the ArtCraft Discord შემოუერთდით ArtCraft-ის Discord-ს + Report an Issue ხარვეზის შეტყობინება + Load Files into Stack ფაილების დასტაში ჩატვირთვა + Statistics სტატისტიკა + Duplicate Layers შრეების დუბლირება + Edit Toolbar ხელსაწყოთა ზოლის რედაქტირება + Import Brushes ფუნჯების იმპორტი + Load 3D LUT 3D LUT-ის ჩატვირთვა + New document ახალი დოკუმენტი + Press keys დააჭირეთ კლავიშებს + Rename Group ჯგუფის გადარქმევა + Rename Tool Preset ხელსაწყოს პრესეტის გადარქმევა + Search commands, tools and panels ბრძანებების, ხელსაწყოებისა და პანელების ძიება + Slice Options ნაჭრის პარამეტრები + Rename Brush ფუნჯის გადარქმევა + Opening იხსნება + Reading იკითხება + Working მუშავდება + Accented Edges აქცენტირებული კიდეები + Angled Strokes დახრილი შტრიხები + Anisotropic ანიზოტროპული + Around Center ცენტრის გარშემო + Artistic მხატვრული + Bamboo ბამბუკი + Bas Relief ბარელიეფი + Berries კენკრა + Blast აფეთქება + Bleach Bypass გაუთეთრებელი დამუშავება + Blocks ბლოკები + Brick აგური + Brush Strokes ფუნჯის შტრიხები + Burlap ტომრის ქსოვილი + Candle Light სანთლის შუქი + Circle წრე + Clumped შეჯგუფებული + Coarse Dots მსხვილი წერტილები + Colored Pencil ფერადი ფანქარი + Contrasty კონტრასტული + Cool ცივი + Craquelure კრაკელიური + Cross ჯვარი + Cross Process ჯვარედინი დამუშავება + Crosshatch ჯვარედინი დაშტრიხვა + Cutout აპლიკაცია + Daisies გვირილები + Dark Rough მუქი უხეში + Dark Strokes მუქი შტრიხები + Darken Only მხოლოდ ჩამუქება + Day For Night დღე ღამედ + Deep Blue მუქი ლურჯი + Deep Emerald მუქი ზურმუხტისფერი + Deep Red მუქი წითელი + Deep Yellow მუქი ყვითელი + Dot წერტილი + Double Line ორმაგი ხაზი + Draft მონახაზი + Dry Brush მშრალი ფუნჯი + Duplication დუბლირება + Edge Extension კიდის გაგრძელება + Edge Only მხოლოდ კიდე + Enlarged გადიდებული + Even Fields ლუწი ველები + Faded Film გახუნებული ფირი + Film Grain ფირის მარცვლოვნება + Fine წვრილი + Fine Dots წვრილი წერტილები + Flat ბრტყელი + Fresco ფრესკა + From Left მარცხნიდან + From Right მარჯვნიდან + Frosted მქრქალი + Generic ზოგადი + Glass მინა + Glowing Edges მანათობელი კიდეები + Grainy Dots მარცვლოვანი წერტილები + Hearts გულები + Heptagon შვიდკუთხედი + Hexagon ექვსკუთხედი + Horizontal Only მხოლოდ ჰორიზონტალური + Infinite უსასრულო + Ink Outlines მელნის კონტურები + Ivy სურო + Left Diagonal მარცხენა დიაგონალი + Level Based დონეზე დაფუძნებული + Light Rough ღია უხეში + Lighten Only მხოლოდ გაღიავება + Long Lines გრძელი ხაზები + Long Strokes გრძელი შტრიხები + Lower ქვედა + Medium Dots საშუალო წერტილები + Medium Lines საშუალო ხაზები + Medium Strokes საშუალო შტრიხები + Mono Contrast მონოკონტრასტი + Mosaic Tiles მოზაიკის ფილები + Multiple Flames Along Path მრავალი ალი ტრაექტორიის გასწვრივ + Multiple Flames One Direction მრავალი ალი ერთი მიმართულებით + Multiple Flames Path Directions მრავალი ალი ტრაექტორიის მიმართულებებით + Multiple Flames Various Length მრავალი ალი სხვადასხვა სიგრძით + Octagon რვაკუთხედი + Odd Fields კენტი ველები + One Flame Along Path ერთი ალი ტრაექტორიის გასწვრივ + Out From Center ცენტრიდან გარეთ + Oval ოვალი + Overlay Edge კიდის გადაფარვა + Palette Knife პალიტრის დანა + Parallel პარალელური + Patchwork ნაჭრების მოზაიკა + Pentagon ხუთკუთხედი + Point წერტილი + Pointed წვეტიანი + Pond Ripples ტბორის ტალღები + Pyramids პირამიდები + Random შემთხვევითი + Right Diagonal მარჯვენა დიაგონალი + Rope თოკი + Roses ვარდები + Sandstone ქვიშაქვა + Scallops ნიჟარები + Sepia სეპია + Short Lines მოკლე ხაზები + Short Strokes მოკლე შტრიხები + Simple მარტივი + Light Intensity სინათლის ინტენსივობა + Spray Radius შესხურების რადიუსი + Stroke Direction შტრიხის მიმართულება + Graininess მარცვლოვნება + Glow Amount ნათების ოდენობა + Clear Amount სისუფთავის ოდენობა + Distortion დეფორმაცია + Ripple Size ტალღის ზომა + Ripple Magnitude ტალღის სიდიდე + Charcoal Area ნახშირის არე + Chalk Area ცარცის არე + Charcoal Thickness ნახშირის სისქე + Light Dark Balance ღია-მუქის ბალანსი + Foreground Level წინა პლანის დონე + Background Level ფონის დონე + Pattern Type პატერნის ტიპი + Image Balance გამოსახულების ბალანსი + Darkness სიმუქე + Fiber Length ბოჭკოს სიგრძე + Crack Spacing ბზარებს შორის მანძილი + Crack Depth ბზარის სიღრმე + Crack Brightness ბზარის სიკაშკაშე + Grain Type მარცვლის ტიპი + Tile Size ფილის ზომა + Grout Width ნაკერის სიგანე + Lighten Grout ნაკერის გაღიავება + Square Size კვადრატის ზომა + Border Thickness საზღვრის სისქე + Correct Distortion დეფორმაციის კორექცია + Correct Vignette ვინიეტის კორექცია + Correct C A ქრომატული აბერაციის კორექცია + Auto Scale ავტომატური მასშტაბირება + Red Cyan წითელი/ცისფერი + Blue Yellow ლურჯი/ყვითელი + Vignette Amount ვინიეტის ოდენობა + Vignette Midpoint ვინიეტის შუა წერტილი + Constraints შეზღუდვები + Curve Highlights მრუდის ღია ტონები + Curve Lights მრუდის ნათელი ტონები + Curve Darks მრუდის მუქი ტონები + Curve Shadows მრუდის ჩრდილები + Curve Splits მრუდის გამყოფები + Point Curve წერტილოვანი მრუდი + Point Curve Red წერტილოვანი მრუდი, წითელი + Point Curve Green წერტილოვანი მრუდი, მწვანე + Point Curve Blue წერტილოვანი მრუდი, ლურჯი + Hsl Hue HSL ფერის ტონი + Hsl Sat HSL გაჯერება + Hsl Lum HSL სიკაშკაშე + Grade Shadows გრადირება: ჩრდილები + Grade Midtones გრადირება: შუალედური ტონები + Grade Highlights გრადირება: ღია ტონები + Grade Global გრადირება: გლობალური + Grade Blending გრადირება: შერევა + Grade Balance გრადირება: ბალანსი + Sine სინუსი + Sketch ესკიზი + Snowflakes ფიფქები + Sparkle ბრჭყვიალი + Spatter შხეფები + Speckle ლაქები + Spin ბრუნვა + Sprinkles მოფრქვეული ნაწილაკები + Squareness კვადრატულობა + Stagger ჭადრაკული წანაცვლება + Stained Glass ვიტრაჟი + Star ვარსკვლავი + Stars ვარსკვლავები + Stippled პუნქტირებული + Stretch გაწელვა + Teal Orange ზღვისფერი/ნარინჯისფერი + Tiny Lens პატარა ლინზა + To Center ცენტრისკენ + Unaltered უცვლელი + Underpainting ქვედა ფენის ხატვა + Underwater წყალქვეშა + Uniform ერთგვაროვანი + Upper ზედა + Vertical Only მხოლოდ ვერტიკალური + Vine With Flowers ვაზი ყვავილებით + Vine With Leaves ვაზი ფოთლებით + Violent მძვინვარე + Warm თბილი + Watercolor აკვარელი + Waves ტალღები + Wide Blurry ფართო ბუნდოვანი + Wide Sharp ფართო მკვეთრი + Wrap შემოხვევა + Zigzag ზიგზაგი + Wetness სინოტივე + AI Integrations AI ინტეგრაციები + Add Noise ხმაურის დამატება + Apply Data Set მონაცემთა ნაკრების გამოყენება + Apply Image გამოსახულების გამოყენება + Arbitrary ნებისმიერი + Artboard from Group სამუშაო დაფა ჯგუფიდან + Artboard from Layers სამუშაო დაფა შრეებიდან + Artboards to Files სამუშაო დაფები ფაილებად + Artboards to PDF სამუშაო დაფები PDF-ად + Assign Profile პროფილის მინიჭება + At მდებარეობა + Auto-Align Layers შრეების ავტომატური სწორება + Auto-Blend Layers შრეების ავტომატური შერევა + Batch პაკეტური დამუშავება + Border საზღვარი + Box Blur კვადრატული გაბუნდოვნება + Calculations გამოთვლები + Camera Raw Camera Raw + Camera Raw Filter Camera Raw ფილტრი + Channel1 არხი1 + Channel2 არხი2 + Channel3 არხი3 + Channel4 არხი4 + Clone კლონირება + Color Decontaminate ფერის გაწმენდა + Color Halftone ფერადი ნახევარტონი + Color Lookup Tables ფერების ცხრილები (Color Lookup) + Color Settings ფერის პარამეტრები + Color Table ფერთა ცხრილი + Conditional Mode Change პირობითი რეჟიმის შეცვლა + Content-Aware Fill შიგთავსის გათვალისწინებით შევსება + Convert to Linked დაკავშირებულად გარდაქმნა + Convert to Profile პროფილში გარდაქმნა + Crystallize კრისტალიზაცია + Data მონაცემები + Data Sets as Files მონაცემთა ნაკრებები ფაილებად + De-Interlace სტრიქონგამოტოვების მოცილება + Define Brush Preset ფუნჯის პრესეტის განსაზღვრა + Define Custom Shape საკუთარი ფიგურის განსაზღვრა + Define Pattern პატერნის განსაზღვრა + Defringe კიდის შარავანდის მოცილება + Diffuse დიფუზია + Diffuse Glow გაბნეული ნათება + Displace ჩანაცვლება რუკით + Do you want to save you made to “{name}” before quitting? გსურთ „{name}“-ში შეტანილი ცვლილებების შენახვა გასვლამდე? + Dust & Scratches მტვერი და ნაკაწრები + Export Contents შიგთავსის ექსპორტი + Export Preferences ექსპორტის პარამეტრები + Export/Import Presets პრესეტების ექსპორტი/იმპორტი + Extrude ამოწევა + Fibers ბოჭკოები + Field Blur ველის გაბუნდოვნება + File Info ფაილის ინფორმაცია + Filter Gallery ფილტრების გალერეა + Find and Replace Text ტექსტის ძებნა და ჩანაცვლება + Fit Image გამოსახულების ჩატევა + Flame ალი + Focus Area ფოკუსის არე + Gaussian Blur გაუსის გაბუნდოვნება + Global Light გლობალური განათება + Group from Layers ჯგუფი შრეებიდან + HDR Toning HDR ტონირება + High Pass მაღალსიხშირული ფილტრი + Highlight ღია ტონი + Image Processor გამოსახულებების დამმუშავებელი + Install Plug-in პლაგინის დაყენება + Interpret Footage ვიდეომასალის ინტერპრეტაცია + Iris Blur ირისის გაბუნდოვნება + Jag დაკბილულობა + Label იარლიყი + Layer Comps to Files შრეების კომპოზიციები ფაილებად + Layer Content Options შრის შიგთავსის პარამეტრები + Layer From Background შრე ფონიდან + Layer from Background შრე ფონიდან + Layers to Files შრეები ფაილებად + Legacy მოძველებული + Lens Blur ლინზის ბუნდოვნება + Lens Correction ობიექტივის კორექცია + Lens Flare ობიექტივის ანარეკლი + Lighting Effects განათების ეფექტები + Liquify გათხევადება + Lock Layers შრეების ჩაკეტვა + Lum სიკ. + Match Color ფერის შეთანხმება + Merge to HDR Pro HDR Pro-ში შერწყმა + Mezzotint მეცოტინტო + Mosaic მოზაიკა + Motion Blur მოძრაობის გაბუნდოვნება + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. ფაილების ჩაშენებული გადათრევა-ჩაგდება Wayland-ზე ჯერ მხარდაჭერილი არ არის. გამოიყენეთ ფაილი › გახსნა, ან გაუშვით PhotoCraft XWayland-ში: `WAYLAND_DISPLAY= photocraft`. + Neon Glow ნეონის ნათება + New Guide Layout დამხმარე ხაზების ახალი განლაგება + New Video Layer from File ახალი ვიდეოშრე ფაილიდან + Ocean Ripple ოკეანის ტალღები + Oil Paint ზეთის საღებავი + Open As გახსნა როგორც + Package შეფუთვა + Paint Daubs საღებავის ლაქები + Path Blur ტრაექტორიის გასწვრივ გაბუნდოვნება + Paths to Illustrator ტრაექტორიები Illustrator-ში + Photomerge Photomerge + Picture Frame სურათის ჩარჩო + Pinch მოჭყლეტა + Place Embedded ჩაშენებულის განთავსება + Place Linked დაკავშირებულის განთავსება + Place Scale Marker მასშტაბის ნიშნულის განთავსება + Plastic Wrap პლასტიკური შეფუთვა + Pointillize პუანტილიზმი + Polar Coordinates პოლარული კოორდინატები + Polar To Rectangular პოლარულიდან მართკუთხაში + Poster Edges პოსტერის კიდეები + Preset Manager პრესეტების მართვა + Profile პროფილი + Radial Blur რადიალური გაბუნდოვნება + Raw Defaults Raw ნაგულისხმევი პარამეტრები + Rectangular To Polar მართკუთხიდან პოლარულში + Reduce Noise ხმაურის შემცირება + Relink to File ფაილთან ხელახლა დაკავშირება + Render Video ვიდეოს რენდერი + Replace Color ფერის ჩანაცვლება + Replace Contents შიგთავსის ჩანაცვლება + Replace Footage ვიდეომასალის ჩანაცვლება + Resolve Missing Fonts დაკარგული შრიფტების აღდგენა + Ripple ტალღა + Rough Pastels უხეში პასტელი + Sat გაჯ. + Save a Copy ასლის შენახვა + Save for Web (Legacy) ვებისთვის შენახვა (მოძველებული) + Scale Effects ეფექტების მასშტაბირება + Script Events Manager სკრიპტის მოვლენების მართვა + Search menus მენიუების ძიება + Seed საწყისი რიცხვი + Select and Mask მონიშვნა და ნიღაბი + Set Measurement Scale გაზომვის მასშტაბის დაყენება + Shadows/Highlights ჩრდილები/ღია ტონები + Shape Blur ფიგურით გაბუნდოვნება + Shear ცვლა + Smart Blur ჭკვიანი გაბუნდოვნება + Smart Sharpen ჭკვიანი გამკვეთრება + Smudge Stick გამღაბნელი ჩხირი + Solid Color მყარი ფერი + Spherize სფერიზება + Spin Blur ბრუნვითი გაბუნდოვნება + Spot პროჟექტორი + Sprayed Strokes შესხურებული შტრიხები + Strokes შტრიხები + Sumi-e სუმი-ე + Surface Blur ზედაპირის გაბუნდოვნება + Texturizer ტექსტურირება + Tiles ფილები + Tilt-Shift დახრა-წანაცვლება + Tool ხელსაწყო + Trace Contour კონტურის მოხაზვა + Trap ტრეპინგი + Tree ხე + Trim შეკვეცა + Turbulent ტურბულენტობა + Twirl გრიგალი + Unsharp Mask არამკვეთრი ნიღაბი + Use File › Open to open an image. გამოსახულების გასახსნელად გამოიყენეთ ფაილი › გახსნა. + Vanishing Point გაქრობის წერტილი + Variable Data Sets ცვლადი მონაცემთა ნაკრებები + Video Frames to Layers ვიდეოკადრები შრეებად + WIA Support WIA მხარდაჭერა + Warp Text ტექსტის დეფორმაცია + Wind ქარი + ZigZag ზიგზაგი + Vertical Type Tool ვერტიკალური ტექსტის ხელსაწყო + About {app} {app}-ის შესახებ + Hide {app} {app}-ის დამალვა + Quit {app} {app}-იდან გასვლა + Services სერვისები + Hide Others სხვების დამალვა + Lut LUT + Cooling L B B გამაციებელი LBB + Cooling80 გამაციებელი 80 + Cooling82 გამაციებელი 82 + Hsb HSB + Hsl HSL + Movie Prime ფიქსირებული კინოობიექტივი + Prime105 ფიქსირებული 105 მმ + Prime35 ფიქსირებული 35 მმ + Rgb RGB + Sponge ღრუბელი + Warming L B A გამათბობელი LBA + Warming81 გამათბობელი 81 + Warming85 გამათბობელი 85 +@id filter.sharpen.sharpen გამკვეთრება + Good კარგი + Best საუკეთესო + Adjustment Properties კორექციის თვისებები + Default Foreground and Background Colors ნაგულისხმევი წინა პლანისა და ფონის ფერები + Move Layer შრის გადაადგილება + Rectangular Selection მართკუთხა მონიშვნა + Reorder Layer შრის გადალაგება + Select Layer შრის მონიშვნა + Set Colors ფერების დაყენება + Switch Foreground and Background Colors წინა პლანისა და ფონის ფერების შენაცვლება + Target სამიზნე +# Print dialog (#2697) + Emulsion Down ემულსია ქვემოთ + Functions ფუნქციები + Print Resolution: ბეჭდვის გარჩევადობა: + + Merge Group ჯგუფის შერწყმა + Manage submitted jobs in the system print queue სისტემის ბეჭდვის რიგში გაგზავნილი დავალებების მართვა + Enable animations ანიმაციების ჩართვა + Saving {percent} ინახება: {percent} + Transform On Drop გარდაქმნა ჩაგდებისას + Scale or rotate the moved area, then press {key} გადაადგილებული არის მასშტაბირება ან შემობრუნება, შემდეგ დააჭირეთ {key}-ს + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close გადათრევა: თავისუფალი ხატვა · {alt}-დაწკაპუნება: სწორი მონაკვეთი · გაშვება საწყის წერტილზე: დახურვა + Amount: რაოდენობა: + Protect: დაცვა: + Protect skin tones კანის ტონების დაცვა + Edit points to modify the curve მრუდის შესაცვლელად წერტილების რედაქტირება + Draw to modify the curve მრუდის შესაცვლელად ხატვა + Smooth the curve values მრუდის მნიშვნელობების გაგლუვება + Drag to draw the curve · {key}-click draws a straight line გადაათრიეთ მრუდის დასახატად · {key}-დაწკაპუნება ხაზავს სწორ ხაზს + Grid Size: ბადის ზომა: + Vanishing Point: click four corners to create a plane გაქრობის წერტილი: სიბრტყის შესაქმნელად დაწკაპეთ ოთხ კუთხეზე + Vanishing Point is not active გაქრობის წერტილი არ არის აქტიური + Apply Vanishing Point ({key}) გაქრობის წერტილის გამოყენება ({key}) + Fix the red plane first: its corners must form a convex quad ჯერ გაასწორეთ წითელი სიბრტყე: მისი კუთხეები ამოზნექილ ოთხკუთხედს უნდა ქმნიდეს + Use Previous Layer to Create Clipping Mask წინა შრის გამოყენება მომჭრელი ნიღბის შესაქმნელად + Fill with {mode}-neutral color (white) შევსება {mode}-ისთვის ნეიტრალური ფერით (თეთრი) + Fill with {mode}-neutral color (black) შევსება {mode}-ისთვის ნეიტრალური ფერით (შავი) + Fill with {mode}-neutral color (50% gray) შევსება {mode}-ისთვის ნეიტრალური ფერით (50% ნაცრისფერი) + (No neutral color exists for {mode} mode.) ({mode} რეჟიმისთვის ნეიტრალური ფერი არ არსებობს.) + The layer or its mask changed. Drag the mask onto the Trash again. შრე ან მისი ნიღაბი შეიცვალა. კვლავ გადაათრიეთ ნიღაბი ნაგვის ყუთზე. + Light Blue ღია ლურჯი + Light Red ღია წითელი + Medium Blue საშუალო ლურჯი + Light Gray ღია ნაცრისფერი + Remove after each stroke ყოველი შტრიხის შემდეგ მოშორება + Apply the removal ({key}) მოშორების გამოყენება ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) მოსაშორებელი შტრიხი: {n} (Enter — გამოყენება, Esc — გაუქმება) + Simulate Pressure დაწოლის იმიტაცია + Stroke Path… ტრაექტორიის კონტური… + Background Eraser ფონის საშლელი + Healing Brush აღმდგენი ფუნჯი + Spot Healing Brush წერტილოვანი აღმდგენი ფუნჯი + Smudge გადღაბნა +curves Smooth გაგლუვება + Apply mask to layer before removing? გამოვიყენო ნიღაბი შრეზე მოშორებამდე? + Delete the vector mask? წავშალო ვექტორული ნიღაბი? +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} ფერის ნიმუში: {n} ექსპორტირებულია {file}-ში +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}-დან იმპორტირებულია ფუნჯი: {n} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}-დან იმპორტირებულია ფერის ნიმუში: {n} +@plural Played {n} step|Played {n} steps შესრულებულია ნაბიჯი: {n} +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk განახლდა დისკზე შეცვლილი დაკავშირებული ჭკვიანი ობიექტი: {n} +@plural Saved for Web: {n} file|Saved for Web: {n} files შენახულია ვებისთვის: ფაილი: {n} +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. ამ კომპოზიციის მიერ ჩაწერილი შრე წაიშალა: {n}. განაახლეთ კომპოზიცია ან გაასუფთავეთ გაფრთხილება. +@plural {n} plane|{n} planes სიბრტყე: {n} +@plural {n} preset|{n} presets პრესეტი: {n} +@plural {n} sample point removed|{n} sample points removed მოშორებულია სინჯის წერტილი: {n} +@plural {n} slice|{n} slices ნაჭერი: {n} +@plural {n} step|{n} steps ნაბიჯი: {n} +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ფუნჯის პრესეტის კიდევ ერთი გაფრთხილება: +{n}) +@plural {what} with {n} warning|{what} with {n} warnings {what}, გაფრთხილება: {n} + Duplicate Channel არხის დუბლირება + Delete Channel არხის წაშლა + Rename Channel… არხის გადარქმევა… + Merge Spot Channel სპოტ-ფერის არხის შერწყმა + Convert to Alpha Channel ალფა-არხად გარდაქმნა + Exit Quick Mask სწრაფი ნიღბიდან გამოსვლა + New Channel… ახალი არხი… + New Spot Channel… ახალი სპოტ-ფერის არხი… + Split Channels არხების დაყოფა + Merge Channels… არხების შერწყმა… + Color Indicates Masked Areas ფერი აღნიშნავს ნიღბიან არეებს + Color Indicates Selected Areas ფერი აღნიშნავს მონიშნულ არეებს + Up ზემოთ + To საბოლოო + Make Work Path… სამუშაო ტრაექტორიის შექმნა… + Create Vector Mask ვექტორული ნიღბის შექმნა + Make Selection… მონიშნულის შექმნა… + Fill Path… ტრაექტორიის შევსება… + Clipping Path… ჩაჭრის ტრაექტორია… + Free Transform Path ტრაექტორიის თავისუფალი გარდაქმნა + Unite Shapes at Overlap ფიგურების გაერთიანება გადაფარვის არეში + Subtract Shapes at Overlap ფიგურების გამოკლება გადაფარვის არეში + Copy Fill შევსების კოპირება + Copy Complete Stroke სრული კონტურის კოპირება + Paste Fill შევსების ჩასმა + Paste Complete Stroke სრული კონტურის ჩასმა + Make Symmetry Path სიმეტრიის ტრაექტორიის შექმნა + Disable Symmetry Path სიმეტრიის ტრაექტორიის გამორთვა + Dark Gray მუქი ნაცრისფერი + Medium Gray საშუალო ნაცრისფერი + Select Custom Color… მორგებული ფერის არჩევა… + Color Picker (Custom Canvas Color) ფერის ამრჩევი (ტილოს მორგებული ფერი) + ✓ Color Indicates Masked Areas ✓ ფერი აღნიშნავს ნიღბიან არეებს + ✓ Color Indicates Selected Areas ✓ ფერი აღნიშნავს მონიშნულ არეებს + Shallow ზედაპირული + Deep ღრმა + Blend Interior Effects as Group შიდა ეფექტების შერევა ჯგუფად + Blend Clipped Layers as Group ჩაჭრილი შრეების შერევა ჯგუფად + Transparency Shapes Layer გამჭვირვალობა განსაზღვრავს შრის ფორმას + Layer Mask Hides Effects შრის ნიღაბი მალავს ეფექტებს + Vector Mask Hides Effects ვექტორული ნიღაბი მალავს ეფექტებს + General Blending ზოგადი შერევა + This Layer: ეს შრე: + Underlying Layer: ქვედა შრე: + Nothing to paste: the clipboard holds no image ჩასასმელი არაფერია: გაცვლის ბუფერში გამოსახულება არ არის + Preferences saved პარამეტრები შენახულია + Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგა ნაგულისხმევი დოკი + Preferences were reset: {error} პარამეტრები ჩამოიყარა: {error} + Recovered {name} აღდგენილია {name} + Couldn't save preferences: {error}. Retrying… პარამეტრების შენახვა ვერ მოხერხდა: {error}. ხელახლა ცდა… + Autosave failed: {error} ავტოშენახვა ვერ მოხერხდა: {error} + Exported presets to {path} პრესეტები ექსპორტირებულია: {path} + Ran script {name} სკრიპტი შესრულდა: {name} + Sent to the printer ({spooler}) გაიგზავნა პრინტერზე ({spooler}) + Printed to {path} დაიბეჭდა: {path} + Exported {path} ({size}) ექსპორტირებულია: {path} ({size}) + Exported {path} ექსპორტირებულია: {path} + Added swatch {name} დაემატა ფერის ნიმუში: {name} + Step {step} ({id}) failed: {error} ნაბიჯი {step} ({id}) ვერ შესრულდა: {error} + Could not rename the action: {error} მოქმედების გადარქმევა ვერ მოხერხდა: {error} + Action playback failed: {error} მოქმედების შესრულება ვერ მოხერხდა: {error} + Clone source set at {x}, {y} კლონირების წყარო დაყენდა: {x}, {y} + Color management: {error} ფერების მართვა: {error} + Opened {url} გაიხსნა: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes მორგებული ფიგურა „{name}“ არ არსებობს: აირჩიეთ პარამეტრების ზოლში ან ფანჯარა › ფიგურები + Saved a copy as {path} ასლი შენახულია როგორც {path} + Exported contents to {path} შიგთავსი ექსპორტირებულია: {path} + {label} is not available: {reason} {label} მიუწვდომელია: {reason} + Saved {path}; image assets: {n} in {dir} შენახულია {path}; გამოსახულების რესურსები: {n}, {dir}-ში + {message} (notes: {n}; {warning}) {message} (შენიშვნა: {n}; {warning}) + Preferences can't be saved პარამეტრების შენახვა შეუძლებელია + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft კვლავ ცდილობს; სანამ შენახვა არ მოხერხდება, პარამეტრების ცვლილებები დახურვისას დაიკარგება. + GPU retry scheduled GPU-ს ხელახალი ცდა დაგეგმილია + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. შეინახეთ ნამუშევარი და გადატვირთეთ PhotoCraft GPU-აჩქარების ხელახლა საცდელად. ამ სესიაში CPU-რენდერინგი აქტიური რჩება. + GPU device was lost; using the CPU renderer. GPU მოწყობილობა დაიკარგა; გამოიყენება CPU-რენდერერი. + GPU error; using the CPU renderer. GPU-ს შეცდომა; გამოიყენება CPU-რენდერერი. + Couldn't open {name}: {error} {name}-ის გახსნა ვერ მოხერხდა: {error} + Toggle the Brush Settings panel ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა + New Brush Preset ფუნჯის ახალი პრესეტი + Grid view ბადის ხედი + List view სიის ხედი + {verb} {mask} {layer} {verb}: {mask}, {layer} + Unlink კავშირის გაწყვეტა + Link დაკავშირება + layer mask შრის ნიღაბი + vector mask ვექტორული ნიღაბი + Commit transform გარდაქმნის დადასტურება + Cancel transform გარდაქმნის გაუქმება + Scroll menu up მენიუს ზემოთ გადახვევა + Scroll menu down მენიუს ქვემოთ გადახვევა + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? გასაგრძელებლად ეს ტექსტის შრე უნდა დარასტრირდეს. მისი ტექსტი აღარ იქნება რედაქტირებადი. დავარასტრირო ტექსტი? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? გასაგრძელებლად ეს ფიგურის შრე უნდა დარასტრირდეს. ის ფიგურად აღარ იქნება რედაქტირებადი. დავარასტრირო ფიგურა? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? გასაგრძელებლად ეს ჭკვიანი ობიექტი უნდა დარასტრირდეს. მისი შიგთავსი აღარ იქნება რედაქტირებადი. დავარასტრირო ჭკვიანი ობიექტი? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? გასაგრძელებლად ეს შევსების შრე უნდა დარასტრირდეს. მისი შევსება აღარ იქნება რედაქტირებადი. დავარასტრირო შრე? + This layer must be rasterized before proceeding. გასაგრძელებლად ეს შრე უნდა დარასტრირდეს. + Color Picker (Background Color) ფერის ამრჩევი (ფონის ფერი) + Color Picker (Foreground Color) ფერის ამრჩევი (წინა პლანის ფერი) + Adwaita (Light) Adwaita (ღია) + Adwaita (Dark) Adwaita (ბნელი) + Breeze Light Breeze ღია + Breeze Dark Breeze ბნელი + Solarized Dark Solarized ბნელი + Open Recent is unavailable on the web ბოლოს გახსნილი ფაილების გახსნა ვებში მიუწვდომელია + Graphics adapter: {adapter} გრაფიკული ადაპტერი: {adapter} + Backend: {backend} ({type}) ბექენდი: {backend} ({type}) + Driver: {driver} დრაივერი: {driver} + GPU backend preference: {backend} GPU ბექენდის არჩევანი: {backend} + Selected at launch: {backend} არჩეულია გაშვებისას: {backend} + Image compositor: {processor} გამოსახულების კომპოზიტორი: {processor} + Fallback: {reason} სარეზერვო ვარიანტი: {reason} + This session: {reason} ეს სესია: {reason} + Window renderer: {renderer} ფანჯრის რენდერერი: {renderer} + Software adapter პროგრამული ადაპტერი + Graphics adapter გრაფიკული ადაპტერი + unknown უცნობი + unknown type უცნობი ტიპი + auto ავტომატური + Platform: {os} {arch} პლატფორმა: {os} {arch} + previous reading kept; the last re-read failed: {reason} შენარჩუნებულია წინა მონაცემები; ბოლო ხელახალი წაკითხვა ვერ მოხერხდა: {reason} + Monitor profile: {profile} მონიტორის პროფილი: {profile} + Display profiles: {reason} ეკრანის პროფილები: {reason} + Monitor profile in use on {display}: {profile} {display}-ზე გამოყენებული მონიტორის პროფილი: {profile} + Monitor profile in use: {profile} გამოყენებული მონიტორის პროფილი: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ტილო ნაჩვენებია როგორც sRGB. აირჩიეთ მონიტორის პროფილი: რედაქტირება › ფერის პარამეტრები. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. ტილო {display}-ზე ნაჩვენებია როგორც sRGB. აირჩიეთ მონიტორის პროფილი: რედაქტირება › ფერის პარამეტრები. + Display profile not used ეკრანის პროფილი არ გამოიყენება + Monitor profile on {display} (main window): {profile} მონიტორის პროფილი {display}-ზე (მთავარი ფანჯარა): {profile} + Monitor profile on {display}: {profile} მონიტორის პროფილი {display}-ზე: {profile} + manual ხელით + fallback სარეზერვო + The document does not have an embedded colour profile. Working space: {working}. დოკუმენტს არ აქვს ჩაშენებული ფერის პროფილი. სამუშაო სივრცე: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} დოკუმენტს აქვს ჩაშენებული ფერის პროფილი, რომელიც სამუშაო სივრცეს არ შეესაბამება.\nჩაშენებული: {embedded}\nსამუშაო: {working} + Importing {what} from {file}… {file}-დან {what}-ის იმპორტი… + brushes ფუნჯები + gradients გრადიენტები +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {file}-დან იმპორტირებულია ფუნჯი: {n} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {file}-დან იმპორტირებულია გრადიენტი: {n} + Data Set: {name} ({index}/{total}) მონაცემთა ნაკრები: {name} ({index}/{total}) + (none) (არცერთი) +navigator Zoom მასშტაბი + Apply {option}: {state} {option}-ის გამოყენება: {state} + on ჩართული + off გამორთული + This adjustment ({key}) is kept as imported and can't be edited yet. ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით და ჯერ ვერ რედაქტირდება. + Join the ArtCraft Discord ({url}) შემოუერთდით ArtCraft-ის Discord-ს ({url}) + {index} of {total} {index} / {total} + {shortcut} assigned. {shortcut} მინიჭებულია. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} უკვე გამოიყენება ბრძანებისთვის {commands} და OK-ზე დაწკაპუნებისას იქიდან მოიხსნება. + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) კერნინგი: მეტრიკა, ოპტიკური ან მნიშვნელობა em-ის 1/1000-ში (Alt+←/→ კურსორთან) + Basic Latin საბაზო ლათინური + Latin 1 ლათინური 1 + Latin Extended-A ლათინური გაფართოებული-A + Latin Extended-B ლათინური გაფართოებული-B + Greek ბერძნული + Cyrillic კირილიცა + Numbers რიცხვები + Currency ვალუტა + Math Symbols მათემატიკური სიმბოლოები + Arrows ისრები + Save Path ტრაექტორიის შენახვა +cameraRaw Highlights ღია ტონები +cameraRaw Shadows ჩრდილები + Temporary დროებითი + none არცერთი + red წითელი + orange ნარინჯისფერი + yellow ყვითელი + green მწვანე + blue ლურჯი + violet იისფერი + gray ნაცრისფერი + Working Rgb სამუშაო RGB + Working Cmyk სამუშაო CMYK + Working Gray სამუშაო ნაცრისფერი + Policy Rgb RGB-ის წესი + Policy Cmyk CMYK-ის წესი + Policy Gray ნაცრისფერის წესი + Convert გარდაქმნა + Ask On Mismatch შეუსაბამობისას შეკითხვა + Ask On Paste ჩასმისას შეკითხვა + Ask On Missing არარსებობისას შეკითხვა + Intent განზრახვა + Bpc შავი წერტილის კომპენსაცია + Blend Text Gamma ტექსტის ფერების შერევა გამით + Monitor Profile მონიტორის პროფილი + Display-p3 Display P3 + Adobe-rgb-compat Adobe RGB-თავსებადი + Prophoto-compat ProPhoto-თავსებადი + Linear-srgb წრფივი sRGB + Rec2020 Rec. 2020 + Coated-cmyk ცარცული ქაღალდის CMYK + Sgray sGray + Gray-gamma-2.2 Gray Gamma 2.2 +cameraRaw Effects ეფექტები +cameraRaw Exposure ექსპოზიცია +cameraRaw Contrast კონტრასტი +cameraRaw Texture ტექსტურა +cameraRaw Clarity სიცხადე +cameraRaw Dehaze ბურუსის მოცილება +cameraRaw Saturation გაჯერება +cameraRaw Curve მრუდი +cameraRaw Color Mixer ფერების მიქშერი +cameraRaw Midtones შუალედური ტონები +cameraRaw Balance ბალანსი +cameraRaw Detail დეტალი +cameraRaw Radius რადიუსი +cameraRaw Feather კიდეების შერბილება +cameraRaw Midpoint შუა წერტილი +cameraRaw Roundness სიმრგვალე +cameraRaw Size ზომა +document Orientation ორიენტაცია + Arc Lower ქვედა რკალი + Arc Upper ზედა რკალი + Forward Warp წინ დეფორმაცია + Twirl Clockwise საათის ისრის მიმართულებით დაგრეხა + Twirl Counterclockwise საათის ისრის საწინააღმდეგოდ დაგრეხა + Pucker შეჭმუხვნა + Bloat გაბერვა + Push Left მარცხნივ გაწევა + Freeze Mask ნიღბით გაყინვა + Thaw Mask ნიღბის გალღობა + Freeze Lasso ლასოთი გაყინვა + Reconstruct All ყველაფრის რეკონსტრუქცია + Mask None ნიღბის გარეშე +colorRange Invert ინვერსია +layers Fill შევსება +layers Fill: შევსება: +shape Appearance იერსახე + Export As: {n} layers ექსპორტი სხვა ფორმატით: შრე: {n} + Exported {n} files ექსპორტირებულია ფაილი: {n} + A brush preset with this name already exists ფუნჯის პრესეტი ამ სახელით უკვე არსებობს + Action playback stopped მოქმედების შესრულება შეწყდა + Add Mask to Selection ნიღბის მონიშნულში დამატება + Add Open Files გახსნილი ფაილების დამატება + Add to Dictionary ლექსიკონში დამატება + All shortcuts reset to Photoshop defaults. ყველა მალსახმობი დაბრუნდა Photoshop-ის ნაგულისხმევზე. + Allow Continue გაგრძელების დაშვება + Attempt to Automatically Align Source Images წყარო გამოსახულებების ავტომატური სწორების მცდელობა + Auto Add/Delete ავტომატური დამატება/წაშლა + Based On საფუძველი + Bend მოხრა + Capture Brush Size in Preset ფუნჯის ზომის პრესეტში შენახვა + Channel Color არხის ფერი + Choose a format that keeps it, such as Photoshop (PSD), or save a copy. აირჩიეთ ფორმატი, რომელიც მას შეინარჩუნებს, მაგალითად Photoshop (PSD), ან შეინახეთ ასლი. + Choose a gradient file (.grd). აირჩიეთ გრადიენტის ფაილი (.grd). + Clear Brush Controls ფუნჯის პარამეტრების გასუფთავება + Color Indicates: ფერი აღნიშნავს: + Copy Texture to Other Tools ტექსტურის სხვა ხელსაწყოებში კოპირება + Create Smart Object after Loading Layers შრეების ჩატვირთვის შემდეგ ჭკვიანი ობიექტის შექმნა + Default Photoshop Size Photoshop-ის ნაგულისხმევი ზომა + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels გადაათრიეთ ჩამოჭრის ჩარჩო · შიგნით გადათრევით გადაადგილება · კიდეებით ზომის შეცვლა ({ratio} თანაფარდობა, {centre} ცენტრიდან) · Space — გადაადგილება ხაზვისას · {commit} — დადასტურება · Esc — გაუქმება + Drag to pan · Scroll to zoom · Double-click to fit გადათრევა: გადაადგილება · გადახვევა: მასშტაბი · ორმაგი დაწკაპუნება: ჩატევა + Drag: draw freehand · Release on the start point: close გადათრევა: თავისუფალი ხატვა · გაშვება საწყის წერტილზე: დახურვა + Enter a name for the brush preset შეიყვანეთ ფუნჯის პრესეტის სახელი + Files ფაილები + Gradient Preset Options გრადიენტის პრესეტის პარამეტრები + Horizontal Distortion ჰორიზონტალური დეფორმაცია + ICO is limited to 256 × 256 pixels ICO შეზღუდულია 256 × 256 პიქსელით + Import .GRD .GRD-ის იმპორტი + Import Photoshop brushes (.abr) Photoshop-ის ფუნჯების იმპორტი (.abr) + Include Color ფერის ჩართვა + Include Tool Settings ხელსაწყოს პარამეტრების ჩართვა + Ink Characteristics მელნის მახასიათებლები + Insert Stop… შეჩერების ჩასმა… + Intersect Mask with Selection ნიღბის მონიშნულთან გადაკვეთა + Load Actions… მოქმედებების ჩატვირთვა… + Load Settings… პარამეტრების ჩატვირთვა… + Load settings from a Camera Raw .xmp file პარამეტრების ჩატვირთვა Camera Raw-ის .xmp ფაილიდან + Loaded Camera Raw settings from {file} Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან (მხარდაუჭერელი: {n}) + Mask Channel ნიღბის არხი + Message: შეტყობინება: + No Spelling Suggestions მართლწერის შეთავაზებები არ არის + No object found there — drag a rectangle around it იქ ობიექტი ვერ მოიძებნა — გადაათრიეთ მის გარშემო მართკუთხედი + Object Finder ობიექტების მაძიებელი + Paper Height ქაღალდის სიმაღლე + Paper Width ქაღალდის სიგანე + Pick Color From Image ფერის აღება გამოსახულებიდან + R g b RGB + Reset All Locked Settings ყველა ჩაკეტილი პარამეტრის ჩამოყრა + Save Actions… მოქმედებების შენახვა… + Save Settings… პარამეტრების შენახვა… + Save Set… ნაკრების შენახვა… + Save these settings as a Camera Raw .xmp file ამ პარამეტრების შენახვა Camera Raw-ის .xmp ფაილად + Select an action first ჯერ აირჩიეთ მოქმედება + Set horizontal skew ჰორიზონტალური დახრის დაყენება + Set vertical skew ვერტიკალური დახრის დაყენება + Show dialog during playback დიალოგის ჩვენება შესრულებისას + Soft Dark Theme რბილი ბნელი თემა + Soft Light Theme რბილი ღია თემა + Soft dark რბილი ბნელი + Soft light რბილი ღია + Solidity: სიმკვრივე: + Sort By Name სახელის მიხედვით დალაგება + Source Channel წყაროს არხი + Source Files წყარო ფაილები + Source Invert წყაროს ინვერსია + Source1 Channel წყარო 1-ის არხი + Source1 Invert წყარო 1-ის ინვერსია + Source2 Channel წყარო 2-ის არხი + Source2 Invert წყარო 2-ის ინვერსია + Step {step} is waiting for its dialog ნაბიჯი {step} თავის დიალოგს ელოდება + Subtract Mask from Selection ნიღბის მონიშნულიდან გამოკლება + Use: გამოყენება: + Vertical Distortion ვერტიკალური დეფორმაცია + {message} ({note}) {message} ({note}) + {name} (Photoshop) {name} (Photoshop) + {name} can't keep everything in this document {name} ვერ შეინარჩუნებს ამ დოკუმენტის ყველაფერს +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} {file}-დან იმპორტირებულია პატერნი: {n} +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}-დან ჩაიტვირთა მოქმედება: {n} +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file}-ში შეინახა მოქმედება: {n} +@plural {n} action|{n} actions მოქმედება: {n} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet ნაბიჯი, რომელსაც PhotoCraft-ში ჯერ შესატყვისი არ აქვს: {n} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out გამოტოვებული ნაბიჯი, რომელსაც Photoshop-ში შესატყვისი არ აქვს: {n} +liquify Rate სიჩქარე + + Choose Stack Mode: აირჩიეთ დასტის რეჟიმი: + No pin selected სამაგრი არ არის მონიშნული + Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. სამაგრის დასამატებლად დაწკაპეთ გამოსახულებაზე; სამაგრის გადასაადგილებლად გადაათრიეთ ის, გაბუნდოვნების შესაცვლელად კი — მისი რგოლი. Delete წაშლის მონიშნულ სამაგრს. + The document “{name}” does not have an embedded {mode} profile. დოკუმენტს „{name}“ არ აქვს ჩაშენებული {mode} პროფილი. + The document “{name}” has an embedded color profile that does not match the current {mode} working space. დოკუმენტს „{name}“ აქვს ჩაშენებული ფერის პროფილი, რომელიც მიმდინარე {mode} სამუშაო სივრცეს არ შეესაბამება. + Embedded: {embedded} ჩაშენებული: {embedded} + Working: {working} სამუშაო: {working} + Custom background color ფონის მორგებული ფერი + Redrawing გადახატვა + Starting serial საწყისი რიგითი ნომერი + Presentation ჩვენება +@id file.close დახურვა +@id layer.layerMask.apply გამოყენება +@id edit.clear გასუფთავება +layerLabel Red წითელი +layerLabel Magenta მეწამული + Export As: {name} ექსპორტი სხვა ფორმატით: {name} + Color Sampler Tool ფერის სინჯის აღების ხელსაწყო + Proof Setup… საცდელი ჩვენების პარამეტრები… + Toggle Layer Mask Overlay შრის ნიღბის გადაფარვის გადართვა + Increase Type Size ტექსტის ზომის გაზრდა + Decrease Type Size ტექსტის ზომის შემცირება + Increase Type Size by 10 ტექსტის ზომის 10-ით გაზრდა + Decrease Type Size by 10 ტექსტის ზომის 10-ით შემცირება + Make Selection from Path ტრაექტორიიდან მონიშნულის შექმნა + Target Composite Channel კომპოზიტური არხის არჩევა + Target Channel 3 მე-3 არხის არჩევა + Target Channel 4 მე-4 არხის არჩევა + Target Channel 5 მე-5 არხის არჩევა + Target Channel 6 მე-6 არხის არჩევა + Target Channel 7 მე-7 არხის არჩევა + Target Channel 8 მე-8 არხის არჩევა + Target Channel 9 მე-9 არხის არჩევა + Fill with Foreground Color წინა პლანის ფერით შევსება + Fill with Background Color ფონის ფერით შევსება + Fill with Foreground Color, Preserve Transparency წინა პლანის ფერით შევსება, გამჭვირვალობის შენარჩუნებით + Fill with Background Color, Preserve Transparency ფონის ფერით შევსება, გამჭვირვალობის შენარჩუნებით + Decrease Brush Size ფუნჯის ზომის შემცირება + Increase Brush Size ფუნჯის ზომის გაზრდა + Decrease Brush Hardness ფუნჯის სიხისტის შემცირება + Increase Brush Hardness ფუნჯის სიხისტის გაზრდა + Hand Tool (hold) ხელის ხელსაწყო (დაჭერით) + Zoom In (hold, drag to scrub) გადიდება (დაჭერით; გადათრევა — მასშტაბის მდორე ცვლა) + Zoom Out (hold) დაპატარავება (დაჭერით) + Erase with Brush or Pencil (hold) ფუნჯით ან ფანქრით წაშლა (დაჭერით) + Author Title ავტორის თანამდებობა + Copyright Url საავტორო უფლებების URL + Title სათაური + Working-cmyk სამუშაო CMYK + Simulate Paper ქაღალდის იმიტაცია + Warming Filter (85) გამათბობელი ფილტრი (85) + Cw საათის ისრის მიმართულებით + Ccw საათის ისრის საწინააღმდეგოდ + Nearest უახლოესი მეზობელი + R G B RGB + Pattern Fill… პატერნით შევსება… + Any ნებისმიერი + Cmyk CMYK + Dont Enlarge გადიდების გარეშე + Change Find ჩანაცვლება და ძებნა + Case Sensitive რეგისტრის გათვალისწინებით + Whole Word მთლიანი სიტყვა + Forward წინ + Checkerboard ჭადრაკის დაფა + browser preset storage timed out ბრაუზერის პრესეტების საცავმა დროულად არ უპასუხა diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 89c47ef1ac..e15adc066b 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -21,6 +21,7 @@ fn requested_languages_resolve_regional_preferences() { ("id-ID", "id"), ("vi-VN", "vi"), ("sr-RS", "sr"), + ("ka-GE", "ka"), ] { assert_eq!(Lang::from_pref(tag), lang(code), "{tag}"); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b976e395fc..f622dbf6ef 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 21] = [ +pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -220,6 +220,8 @@ pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese; `vi-VN` resolves here. LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + // Georgian: nouns stay singular after a numeral (`შრე: 5`, `5 შრე`), so one form serves every count. + LangInfo { code: "ka", name: "ქართული", source: catalog!("ka"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -477,6 +479,9 @@ mod tests { assert_eq!(lang_from_tag("tr_TR.UTF-8"), Lang::from_code("tr")); assert_eq!(lang_from_tag("tr-TR"), Lang::from_code("tr")); assert_eq!(lang_from_tag("tr"), Lang::from_code("tr")); + assert_eq!(lang_from_tag("ka"), Lang::from_code("ka")); + assert_eq!(lang_from_tag("ka_GE.UTF-8"), Lang::from_code("ka")); + assert_eq!(lang_from_tag("ka-GE"), Lang::from_code("ka")); // Traditional Chinese: by region, by script, and with a region after the script. assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH())); assert_eq!(lang_from_tag("zh-TW"), Some(ZH())); diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 49bc3bc79b..08de4bf43a 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -100,6 +100,7 @@ mod tests { (vec!["tr-TR", "en-US"], "tr"), (vec!["sr-Latn-RS", "en-US"], "sr"), (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), + (vec!["ka-GE", "ru-RU"], "ka"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 07c1e5a7b6..084b65e425 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -782,7 +782,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::slider_row(ui, tl!("Size"), &mut d.opts.size, 1.0..=3000.0, "", None); widgets::slider_row(ui, tl!("Density"), &mut d.opts.density, 0.0..=100.0, "", None); widgets::slider_row(ui, tl!("Pressure"), &mut d.opts.pressure, 0.0..=100.0, "", None); - widgets::slider_row(ui, tl!("Rate"), &mut d.opts.rate, 0.0..=100.0, "", None); + widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "liquify", "Rate"), &mut d.opts.rate, 0.0..=100.0, "", None); widgets::hairline(ui); widgets::section_label(ui, tl!("Brush Reconstruct Options")); widgets::slider_row(ui, tl!("Amount"), &mut d.opts.reconstruct_amount, 0.0..=100.0, "%", None); diff --git a/docs/localization-ka.md b/docs/localization-ka.md new file mode 100644 index 0000000000..1d369f6a08 --- /dev/null +++ b/docs/localization-ka.md @@ -0,0 +1,84 @@ +# Georgian localization and terminology + +This note goes with the Georgian catalog (`crates/ui-egui/src/i18n/ka.tsv`). It covers every key +`cargo xtask i18n-coverage` measures and the runtime labels the other complete catalogs carry +(filter and adjustment parameters, Camera Raw, panels, dialogs, status messages). Translations are +written from the meaning of PhotoCraft's English labels; no proprietary translation resources, +fonts, screenshots or user documents are included. + +## Choosing Georgian + +Open **Edit > Preferences > Interface > Language** (Georgian: **რედაქტირება › პარამეტრები › +ინტერფეისი › ენა**) and choose **ქართული**. With `interface.language` set to `auto`, the system +locales `ka`, `ka-GE` and `ka_GE.UTF-8` resolve to Georgian. + +## Register and style + +Commands are verbal nouns (masdar: *შენახვა*, *გახსნა*, *შრის წაშლა*), not imperatives; prompts +and hints address the user politely (*დაწკაპეთ*, *გადაათრიეთ*). Product and technology names +(PhotoCraft, OpenType, CMYK, Lab) stay as they are, and the trailing `…` of dialog commands is kept. + +## Georgian script + +- Mkhedruli has no letter case, so nothing is capitalised and labels never use Mtavruli. + The text engine's **All Caps** maps Mkhedruli to Mtavruli through Unicode `to_uppercase`, which + is the Unicode-standard behaviour; there is no title-case feature that needs a Georgian guard. +- Nouns stay singular after a numeral, so the catalog uses one plural form and count-neutral + wording: `{n} layers` is `შრე: {n}` (`plural_none` in the registry). +- UI font: PhotoCraft bundles no Georgian face. The fallback list in `crates/text/src/scripts.rs` + uses system fonts: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans and + FreeSans. The web build has no system fonts, so it needs a Georgian face before Georgian can render + there. +- PhotoCraft has no per-language date or number formatting, so nothing Georgian-specific applies. + +## Terminology + +Follow this table when adding or changing Georgian strings. + +| English source | Georgian | Note | +|---|---|---| +| File / Edit / Select / View / Window | ფაილი / რედაქტირება / მონიშვნა / ხედი / ფანჯარა | menu bar | +| Image | გამოსახულება | the edited picture | +| Canvas / Canvas Size… | ტილო / ტილოს ზომა… | | +| Layer / Layers | შრე / შრეები | | +| Selection | მონიშნული | the selected area; the menu is მონიშვნა | +| Layer Mask / Vector Mask | შრის ნიღაბი / ვექტორული ნიღაბი | | +| Clipping Mask | მომჭრელი ნიღაბი | | +| Smart Object | ჭკვიანი ობიექტი | | +| Rasterize | რასტრირება | | +| Path / Work Path | ტრაექტორია / სამუშაო ტრაექტორია | not კონტური | +| Stroke | კონტური | outline of a shape or text | +| Channel / Alpha | არხი / ალფა | | +| Spot Color / Spot Channel | სპოტ-ფერი / სპოტ-ფერის არხი | | +| Adjustments | კორექცია | | +| Blend Mode / Blending Options | შერევის რეჟიმი / შერევის პარამეტრები | | +| Opacity | გაუმჭვირვალობა | | +| Preset / Presets | პრესეტი / პრესეტები | | +| Pattern | პატერნი | | +| Swatches | ფერის ნიმუშები | | +| Slice | ნაჭერი | | +| Crop | ჩამოჭრა | | +| Feather | კიდეების შერბილება | | +| Hue / Lightness | ფერის ტონი / სიღიავე | | +| Threshold | ზღვარი | | +| Brush / Eraser / Smudge | ფუნჯი / საშლელი / გადღაბნა | | +| Healing Brush | აღმდგენი ფუნჯი | | +| Reset | ჩამოყრა | | +| Paste | ჩასმა | | +| Workspace | სამუშაო სივრცე | | +| Foreground / Background Color | წინა პლანის ფერი / ფონის ფერი | | + +## Not covered + +Engine error and status messages, user document, layer and preset names, automation command IDs +and the default `Untitled` / `Background` names stay in English, as in every language. + +## Maintenance + +Add a row to `ka.tsv` for each new English label: `contextsourcetranslation`, with +`@plural` rows holding one Georgian form and `@id` rows overriding a command id. Keep `{name}` +placeholders, `\n`, `…` and shortcut text exactly as in the source. Checks: + +- `cargo test -p photocraft-ui-egui --lib i18n`: registry, locale tags, placeholders and catalog + consistency. +- `cargo xtask i18n-coverage`: measured key coverage for `ka`. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c73eb094b..7e9b464833 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Georgian catalog; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), -[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md). +[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Georgian](localization-ka.md). **How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog @@ -22,7 +22,7 @@ Vietnamese UI; Arabic and Hebrew users get English "Middle East" builds with the composer, and its text engine handles RTL, Devanagari and Indic scripts. **Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the -engine messages), 3 absent (Hindi, Arabic, Vietnamese), plus 8 other languages. Weighted by +engine messages), 3 absent (Hindi, Arabic, Vietnamese), plus 9 other languages. Weighted by speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorded only for Italian, 2026-10-12). @@ -45,10 +45,11 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde ## Other shipped languages -8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional +9 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the -other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` +other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,165 rows; 1,844 / 1,844 measured keys on 2026-10-14; +Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse the Russian rule. @@ -85,3 +86,4 @@ engine localization or native review is complete. | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | +| 2026-10-14 | minor | Georgian (`ka`) catalog | diff --git a/docs/localization.md b/docs/localization.md index 36e09f8a55..aa914320de 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -28,6 +28,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | | Srpski | `sr` | one / few / many (teens use many) | +| ქართული | `ka` | one form (count-neutral: `შრე: {n}`) | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index d325b2b9d6..036d650022 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -31,6 +31,7 @@ hu tr vi + ka CFBundleDisplayName PhotoCraft diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index ee337c0b9b..1eb5ac768f 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -583,6 +583,7 @@ mod tests { ("tr", "tr.tsv"), ("vi", "vi.tsv"), ("sr", "sr.tsv"), + ("ka", "ka.tsv"), ] { let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); let text = fs::read_to_string(&catalog_path).unwrap_or_default(); From f5241f9704e69039aae9c0a6489d1ad28a6155b8 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 20:55:53 +0400 Subject: [PATCH 008/310] i18n(ka): catalog follows the reviewed translations Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 35ffc16100..8883da6292 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3787,6 +3787,7 @@ layerLabel No Color ფერის გარეშე Functions ფუნქციები Print Resolution: ბეჭდვის გარჩევადობა: +# Added upstream (sync 2026-10-13) Merge Group ჯგუფის შერწყმა Manage submitted jobs in the system print queue სისტემის ბეჭდვის რიგში გაგზავნილი დავალებების მართვა Enable animations ანიმაციების ჩართვა @@ -4072,6 +4073,7 @@ layers Fill: შევსება: shape Appearance იერსახე Export As: {n} layers ექსპორტი სხვა ფორმატით: შრე: {n} Exported {n} files ექსპორტირებულია ფაილი: {n} +# Added upstream (sync 2 2026-10-13) A brush preset with this name already exists ფუნჯის პრესეტი ამ სახელით უკვე არსებობს Action playback stopped მოქმედების შესრულება შეწყდა Add Mask to Selection ნიღბის მონიშნულში დამატება @@ -4081,6 +4083,13 @@ shape Appearance იერსახე Allow Continue გაგრძელების დაშვება Attempt to Automatically Align Source Images წყარო გამოსახულებების ავტომატური სწორების მცდელობა Auto Add/Delete ავტომატური დამატება/წაშლა + +# Native Windows printing qualification + Don't also tick Mirror Image in Print Settings…: the two flips cancel out. ბეჭდვის პარამეტრებში… დამატებით ნუ ჩართავთ „სარკისებურ ასახვას“: ორი ასახვა ერთმანეთს აბათილებს. + Print Settings… ბეჭდვის პარამეტრები… + Remember to turn off the printer's own colour management in Print Settings… არ დაგავიწყდეთ თავად პრინტერის ფერების მართვის გამორთვა ბეჭდვის პარამეტრებში… + Sent to {printer} გაიგზავნა პრინტერზე: {printer} + set in Print Settings… განისაზღვრება ბეჭდვის პარამეტრებში… Based On საფუძველი Bend მოხრა Capture Brush Size in Preset ფუნჯის ზომის პრესეტში შენახვა @@ -4158,6 +4167,7 @@ shape Appearance იერსახე @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out გამოტოვებული ნაბიჯი, რომელსაც Photoshop-ში შესატყვისი არ აქვს: {n} liquify Rate სიჩქარე +# Sync 2026-10-14 (strings added upstream) Choose Stack Mode: აირჩიეთ დასტის რეჟიმი: No pin selected სამაგრი არ არის მონიშნული Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. სამაგრის დასამატებლად დაწკაპეთ გამოსახულებაზე; სამაგრის გადასაადგილებლად გადაათრიეთ ის, გაბუნდოვნების შესაცვლელად კი — მისი რგოლი. Delete წაშლის მონიშნულ სამაგრს. From 000043eefbb6061f2b1c051c11e416765c5663ae Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 15 Oct 2026 00:55:16 +0400 Subject: [PATCH 009/310] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8883da6292..764887f153 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1345,11 +1345,11 @@ Remove Tool ხელსაწყოს მოშორება Remove stop გადასვლის წერტილის წაშლა Remove variable ცვლადის მოშორება - Rename გადარქმევა - Rename Group… ჯგუფის გადარქმევა… + Rename სახელის შეცვლა + Rename Group… ჯგუფის სახელის შეცვლა… Rename Layer… შრის სახელის შეცვლა… - Rename Tool Preset… ხელსაწყოს პრესეტის გადარქმევა… - Rename… გადარქმევა… + Rename Tool Preset… ხელსაწყოს პრესეტის სახელის შეცვლა… + Rename… სახელის შეცვლა… Resample ხელახალი დისკრეტიზაცია Reset All to Defaults ყველაფრის ნაგულისხმევზე დაბრუნება Import Shortcuts… მალსახმობების იმპორტი… @@ -2042,7 +2042,7 @@ Toggle the Brush Settings panel (F5) ფუნჯის პარამეტრების პანელის ჩართვა/გამორთვა (F5) Create new brush preset from the current settings ფუნჯის ახალი პრესეტის შექმნა მიმდინარე პარამეტრებით Brush Settings… (F5): every option of this brush ფუნჯის პარამეტრები… (F5): ამ ფუნჯის ყველა პარამეტრი - Rename Brush… ფუნჯის გადარქმევა… + Rename Brush… ფუნჯის სახელის შეცვლა… Delete Brush ფუნჯის წაშლა New Brush Preset… ფუნჯის ახალი პრესეტი… Brush Preset Options ფუნჯის პრესეტის პარამეტრები @@ -2352,7 +2352,7 @@ Hide დამალვა New Layer Comp შრეების ახალი კომპოზიცია Duplicate Layer Comp შრეების კომპოზიციის დუბლირება - Rename Layer Comp შრეების კომპოზიციის გადარქმევა + Rename Layer Comp შრეების კომპოზიციის სახელის შეცვლა Apply Layer Comp შრეების კომპოზიციის გამოყენება Update Layer Comp შრეების კომპოზიციის განახლება Restore last viewed comp ბოლოს ნანახი კომპოზიციის აღდგენა @@ -3382,11 +3382,11 @@ layerLabel No Color ფერის გარეშე Load 3D LUT 3D LUT-ის ჩატვირთვა New document ახალი დოკუმენტი Press keys დააჭირეთ კლავიშებს - Rename Group ჯგუფის გადარქმევა - Rename Tool Preset ხელსაწყოს პრესეტის გადარქმევა + Rename Group ჯგუფის სახელის შეცვლა + Rename Tool Preset ხელსაწყოს პრესეტის სახელის შეცვლა Search commands, tools and panels ბრძანებების, ხელსაწყოებისა და პანელების ძიება Slice Options ნაჭრის პარამეტრები - Rename Brush ფუნჯის გადარქმევა + Rename Brush ფუნჯის სახელის შეცვლა Opening იხსნება Reading იკითხება Working მუშავდება @@ -3845,7 +3845,7 @@ curves Smooth გაგლუვება @plural {what} with {n} warning|{what} with {n} warnings {what}, გაფრთხილება: {n} Duplicate Channel არხის დუბლირება Delete Channel არხის წაშლა - Rename Channel… არხის გადარქმევა… + Rename Channel… არხის სახელის შეცვლა… Merge Spot Channel სპოტ-ფერის არხის შერწყმა Convert to Alpha Channel ალფა-არხად გარდაქმნა Exit Quick Mask სწრაფი ნიღბიდან გამოსვლა @@ -3902,7 +3902,7 @@ curves Smooth გაგლუვება Exported {path} ექსპორტირებულია: {path} Added swatch {name} დაემატა ფერის ნიმუში: {name} Step {step} ({id}) failed: {error} ნაბიჯი {step} ({id}) ვერ შესრულდა: {error} - Could not rename the action: {error} მოქმედების გადარქმევა ვერ მოხერხდა: {error} + Could not rename the action: {error} მოქმედების სახელის შეცვლა ვერ მოხერხდა: {error} Action playback failed: {error} მოქმედების შესრულება ვერ მოხერხდა: {error} Clone source set at {x}, {y} კლონირების წყარო დაყენდა: {x}, {y} Color management: {error} ფერების მართვა: {error} From 90ae8ce456025db619d351de10c8ad2aa1b1a75a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 15 Oct 2026 02:17:37 +0400 Subject: [PATCH 010/310] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 764887f153..8fe66f410d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4233,3 +4233,34 @@ layerLabel Magenta მეწამული Forward წინ Checkerboard ჭადრაკის დაფა browser preset storage timed out ბრაუზერის პრესეტების საცავმა დროულად არ უპასუხა + Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად, კვადრატი — კუთხეების მოსამრგვალებლად, შიგნით მდებარე წერტილები კი — გაბუნდოვნების დაწყების ადგილის დასაყენებლად. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ ქინძისთავი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. + Drag the handles on the outline to resize and turn it; the ring sets the blur angle. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად; რგოლი გაბუნდოვნების კუთხეს აყენებს. + Add Shortcut მალსახმობის დამატება + Clip thumbnails to layer bounds მინიატიურების შრის საზღვრებით მოჭრა + Clip Thumbnails to Layer Bounds მინიატიურების შრის საზღვრებით მოჭრა + Import Brushes… (.abr or image) ფუნჯების იმპორტი… (.abr ან გამოსახულება) + Import Brush from Image… ფუნჯის იმპორტი გამოსახულებიდან… + Bundle Arrow-Key Nudges ისრებით წანაცვლებების გაერთიანება + Nudge Bundle Pause (ms) გაერთიანებული წანაცვლების პაუზა (მწმ) + Response curve რეაქციის მრუდი + Space ჰარი + W: სიგ.: + A: კუთხე: + Show home recent files ბოლო ფაილების ჩვენება საწყის ეკრანზე + Background (Default) ფონი (ნაგულისხმევი) + Include All Subfolders ყველა ქვესაქაღალდის ჩართვა + Use the embedded profile ({profile}) ჩაშენებული პროფილის გამოყენება ({profile}) + Convert to the working space ({profile}) სამუშაო სივრცეში გარდაქმნა ({profile}) + Remember this choice for future images ამ არჩევანის დამახსოვრება მომავალი გამოსახულებებისთვის + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. შეკითხვის ხელახლა ჩასართავად გამოიყენეთ „შეუსაბამობისას შეკითხვა“: რედაქტირება › ფერის პარამეტრები. + Button Mode ღილაკების რეჟიმი + Button Mode On ღილაკების რეჟიმი ჩართულია + Darken Interface ინტერფეისის გამუქება + Lighten Interface ინტერფეისის გაღიავება + Technique ტექნიკა + Chisel Hard მკვეთრი კვეთა + Chisel Soft რბილი კვეთა + Open color-less EXR as ACEScg ფერის სივრცის გარეშე EXR-ის გახსნა ACEScg-ად + Edit Selected Guides მონიშნული დამხმარე ხაზების რედაქტირება + Clear Selected Guides მონიშნული დამხმარე ხაზების გასუფთავება From cc313d3551207e81783c587726d50eebc3c0ac15 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 15 Oct 2026 12:23:40 +0400 Subject: [PATCH 011/310] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8fe66f410d..150aa923c4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4234,7 +4234,7 @@ layerLabel Magenta მეწამული Checkerboard ჭადრაკის დაფა browser preset storage timed out ბრაუზერის პრესეტების საცავმა დროულად არ უპასუხა Drag the handles on the outline to resize and turn it, the square to round its corners, and the dots inside to set where the blur starts. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად, კვადრატი — კუთხეების მოსამრგვალებლად, შიგნით მდებარე წერტილები კი — გაბუნდოვნების დაწყების ადგილის დასაყენებლად. - Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ ქინძისთავი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. + Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ სამაგრი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. Drag the handles on the outline to resize and turn it; the ring sets the blur angle. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად; რგოლი გაბუნდოვნების კუთხეს აყენებს. Add Shortcut მალსახმობის დამატება Clip thumbnails to layer bounds მინიატიურების შრის საზღვრებით მოჭრა @@ -4264,3 +4264,17 @@ layerLabel Magenta მეწამული Open color-less EXR as ACEScg ფერის სივრცის გარეშე EXR-ის გახსნა ACEScg-ად Edit Selected Guides მონიშნული დამხმარე ხაზების რედაქტირება Clear Selected Guides მონიშნული დამხმარე ხაზების გასუფთავება + Delete this set ამ ნაკრების წაშლა + New Set… ახალი ნაკრები… + PhotoCraft Defaults PhotoCraft-ის ნაგულისხმევი + Save a list of these shortcuts as a web page ამ მალსახმობების სიის ვებგვერდად შენახვა + Save the changes to this set ამ ნაკრებში ცვლილებების შენახვა + Save these shortcuts as a .kys file ამ მალსახმობების .kys ფაილად შენახვა + Save these shortcuts as a new set ამ მალსახმობების ახალ ნაკრებად შენახვა + Set deleted. ნაკრები წაიშალა. + Set saved. ნაკრები შენახულია. + Set: ნაკრები: + Summarize… შეჯამება… + There are too many sets: delete one first. ნაკრები ძალიან ბევრია: ჯერ ერთი წაშალეთ. + set name ნაკრების სახელი + {set} (modified) {set} (შეცვლილი) From 6977140c21726989d347ec012c97839422bc5363 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 17 Oct 2026 04:37:56 +0400 Subject: [PATCH 012/310] i18n(ka): translate strings added upstream Georgian for the 18 strings main added since the last refresh (shortcut conflicts, GPU memory budget, low-latency drawing), and the row main dropped from every catalog removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 150aa923c4..05d3b618da 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2802,7 +2802,6 @@ cameraRaw Whites თეთრები Large document: redrawing on the CPU დიდი დოკუმენტი: გადახატვა CPU-ზე This redraw took {seconds} s on the CPU. ამ გადახატვას CPU-ზე {seconds} წმ დასჭირდა. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. ცვლილებები, რომლებიც მთელ დოკუმენტს ცვლის, მაგალითად შრის შერევის რეჟიმი, გაუმჭვირვალობა ან ხილვადობა, მთელ დოკუმენტს ხელახლა ხატავს. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. მისი შრეები GPU-ის მეხსიერების ლიმიტში არ ეტევა. პარამეტრები › წარმადობა › მეხსიერების გამოყენების გაზრდა ლიმიტს ზრდის, ამ კომპიუტერის მეხსიერების მეოთხედამდე. The GPU compositor wasn't used: {reason} GPU-კომპოზიტორი არ იქნა გამოყენებული: {reason} fontWeight Thin თხელი fontWeight ExtraLight ძალიან ღია @@ -4278,3 +4277,23 @@ layerLabel Magenta მეწამული There are too many sets: delete one first. ნაკრები ძალიან ბევრია: ჯერ ერთი წაშალეთ. set name ნაკრების სახელი {set} (modified) {set} (შეცვლილი) + Accept and Go to Conflict მიღება და კონფლიქტზე გადასვლა + Changes undone. ცვლილებები დაბრუნდა. + Choose a new shortcut for this command, or leave it without one. აირჩიეთ ამ ბრძანების ახალი მალსახმობი ან დატოვეთ ის მალსახმობის გარეშე. + Put this command's shortcuts back as they were ამ ბრძანების მალსახმობების წინანდელ მდგომარეობაში დაბრუნება + Take the shortcut from the other command and select it მალსახმობის მეორე ბრძანებისთვის წართმევა და იმ ბრძანების მონიშვნა + Undo Changes ცვლილებების დაბრუნება + {shortcut} is reserved by the system and can't be assigned. {shortcut} სისტემისთვისაა დაჯავშნული და მისი მინიჭება შეუძლებელია. + Low-Latency Drawing (vsync off, applies at next launch) დაბალი დაყოვნებით ხატვა (vsync გამორთულია, ძალაში შედის შემდეგი გაშვებისას) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. თავისუფალი გარდაქმნის გამოყენება ჯერ არ დასრულებულა; სცადეთ ხელახლა, როცა დასრულდება. + The transform couldn't be applied. გარდაქმნის გამოყენება ვერ მოხერხდა. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. მის შრეებს GPU-ის მეხსიერებიდან სჭირდება {needed}; ლიმიტი მათ უტოვებს {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. პარამეტრები › წარმადობა › GPU-ის მეხსიერება დააყენეთ იმ მოცულობაზე, რასაც თქვენი ვიდეობარათი იტევს, მაქსიმუმ მისი მეხსიერების 3/8-ზე. ავტომატური მნიშვნელობა მიჰყვება მეხსიერების გამოყენებას, ამ კომპიუტერის მეხსიერების მეოთხედისა და ბარათის მეხსიერების 3/8-ის ფარგლებში, ხოლო თუ ბარათის მეხსიერება ცნობილია, დოკუმენტს მის ხარჯზე არ ითვლის. სანამ შრეები არ ეტევა, სტატუსის ზოლში ჩანს „GPU-ის მეხსიერება სავსეა“. + GPU Memory Full GPU-ის მეხსიერება სავსეა + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. შრეებს GPU-ის მეხსიერებიდან სჭირდება {needed}, დასაშვებია კი {budget}, ამიტომ ცვლილებები, რომლებიც მთელ დოკუმენტს ხელახლა ხატავს, ამას CPU-ზე აკეთებს. დაწკაპეთ GPU-ის მეხსიერების დასაყენებლად. + GPU memory budget: {budget} GPU-ის მეხსიერების ლიმიტი: {budget} + This document's layers: {needed} of {budget} for layers ამ დოკუმენტის შრეები: {needed} / {budget} (შრეებისთვის) + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU ამ დოკუმენტის შრეებს სჭირდება {needed}, დასაშვებია კი {budget}: მთელი დოკუმენტის გადახატვა CPU-ს იყენებს + GPU Memory (MB, 0 = Automatic) GPU-ის მეხსიერება (მბ, 0 = ავტომატური) From 313570d753033959cc9a9226e1ebfa4cf11ba2da Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 18 Oct 2026 19:53:31 +0400 Subject: [PATCH 013/310] fix(fonts): serve Noto Sans Georgian on the web like Thai and Arabic; bump craft-fonts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- crates/text/src/served.rs | 8 ++++++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8243353d..83f52a49aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,7 +277,7 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + ref: a71c20e30af613287c504f7e685ca8fad77752bc path: craft-fonts - name: Test with craft-fonts diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 970c6577a4..fa6e7bfe49 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -30,7 +30,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc cargo-build-extra-args: "--features heif" custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1" run-package-check: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfdca9e3e9..db81f742b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: photocraft app-title: PhotoCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 0198aea97a..3d2206fd87 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -11,7 +11,7 @@ //! //! Script fallback: the manifest's `scripts` field says which scripts a font is for. The first //! served family listed for a script the bundled fonts don't cover (Arabic, Hebrew, Devanagari, -//! Thai, Khmer, Lao, Burmese, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it +//! Thai, Khmer, Lao, Burmese, Georgian, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it //! ([`request_for_text`], from layout), the font database puts it in the fallback stack once it //! arrives, and the web shell asks up front for the scripts of the browser's languages //! ([`request_for_languages`]). The manifest's SHA-256 becomes the fetch's Subresource Integrity @@ -63,7 +63,7 @@ fn is_relative_path(p: &str) -> bool { /// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers /// Latin, Greek and Cyrillic) lack, as ISO 15924 codes. -const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Khmr", "Laoo", "Mymr", "Jpan", "Hans", "Hant", "Kore"]; +const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Khmr", "Laoo", "Mymr", "Geor", "Jpan", "Hans", "Hant", "Kore"]; /// The script fallbacks in `fonts`: for each script in [`FALLBACK_SCRIPTS`], the first family in /// manifest order that lists it, as `(script, family)` in order of first appearance. @@ -99,6 +99,7 @@ pub fn script_of(c: char, order: &[crate::cjk::CjkScript; 4]) -> Option<&'static 0x1780..=0x17FF | 0x19E0..=0x19FF => "Khmr", 0x0E80..=0x0EFF => "Laoo", 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F => "Mymr", + 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => "Geor", _ => return None, }) } @@ -117,6 +118,7 @@ pub fn language_script(tag: &str) -> Option<&'static str> { "km" => "Khmr", "lo" => "Laoo", "my" => "Mymr", + "ka" => "Geor", "ja" => "Jpan", "ko" => "Kore", "zh" if rest.iter().any(|r| matches!(*r, "hant" | "tw" | "hk" | "mo")) => "Hant", @@ -411,6 +413,7 @@ mod tests { ('ລ', Some("Laoo")), ('မ', Some("Mymr")), ('ၡ', Some("Mymr")), + ('ა', Some("Geor")), ('あ', Some("Jpan")), ('한', Some("Kore")), ] { @@ -433,6 +436,7 @@ mod tests { ("km-KH", Some("Khmr")), ("lo", Some("Laoo")), ("my-MM", Some("Mymr")), + ("ka-GE", Some("Geor")), ("ja-JP", Some("Jpan")), ("zh-CN", Some("Hans")), ("zh-TW", Some("Hant")), From 2a4aa8461a1d9f46ee745b87c39e05140107626d Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 18 Oct 2026 21:40:17 +0400 Subject: [PATCH 014/310] fix(fonts): find macOS's SF Georgian, as Hebrew finds SF Hebrew Georgian's installed-font candidates had no macOS face, so a Mac (no Sylfaen, no Noto Sans Georgian) would draw the Georgian interface as boxes, as DesignCraft v0.9.0 does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/scripts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/scripts.rs b/crates/text/src/scripts.rs index b6e0aae74f..3f8bfd1cb1 100644 --- a/crates/text/src/scripts.rs +++ b/crates/text/src/scripts.rs @@ -106,7 +106,7 @@ impl Script { ], Hebrew => &["notosanshebrew", "notoserifhebrew", "segoeui", "arialhb", "sfhebrew", "dejavusans", "arial", "freesans", "culmus"], Armenian => &["notosansarmenian", "notoserifarmenian", "segoeui", "sylfaen", "mshtakan", "dejavusans", "freesans"], - Georgian => &["notosansgeorgian", "notoserifgeorgian", "segoeui", "sylfaen", "dejavusans", "freesans"], + Georgian => &["notosansgeorgian", "notoserifgeorgian", "segoeui", "sylfaen", "sfgeorgian", "dejavusans", "freesans"], Ethiopic => &["notosansethiopic", "notoserifethiopic", "ebrima", "kefa", "abyssinicasil"], Devanagari => &[ "notosansdevanagari", From 0adb5a2a23f576b029a3780b976e083ffa775f90 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 03:40:12 +0400 Subject: [PATCH 015/310] docs(i18n): fix Georgian row count and language list in localization-parity Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- docs/localization-parity.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e9b93fba8c..74f2b595ee 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -47,12 +47,9 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde 9 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the -other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,165 rows; 1,844 / 1,844 measured keys on 2026-10-14; -Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans). Turkish `tr` -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the -other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` +other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,234 rows; 1,844 / 1,844 measured keys on 2026-10-14; +Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse the Russian rule. From 62a79594ec035d7d68834a758bb54122b2006d89 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Tue, 20 Oct 2026 09:06:43 +0400 Subject: [PATCH 016/310] i18n(ka): Georgian rows for the strings main added (Content-Aware Fill workspace, memory settings, clipboard messages) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 30 ++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9856949749..06e754ba51 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2988,6 +2988,22 @@ cameraData Lens ობიექტივი Snap to Origin სათავესთან მიბმა Search settings… პარამეტრების ძიება… No matching settings შესაბამისი პარამეტრები არ არის +# Edit › Content-Aware Fill workspace + Sampling Brush Tool ნიმუშის აღების ფუნჯის ხელსაწყო + Sampling Area Overlay ნიმუშის აღების არის გადაფარვა + Show Sampling Area ნიმუშის აღების არის ჩვენება + Indicates აღნიშნავს + Sampling Area ნიმუშის აღების არე + Excluded Area გამორიცხული არე + Sampling Area Options ნიმუშის აღების არის პარამეტრები + Rectangular მართკუთხა + Fill Settings შევსების პარამეტრები + Rotation Adaptation ბრუნვის ადაპტაცია + Very High ძალიან მაღალი + Full სრული + Mirror არეკვლა + Output Settings გამოტანის პარამეტრები + Output To გამოტანის ადგილი Loading fonts… შრიფტები იტვირთება… # Select and Mask view modes (#1754) Onion Skin ხახვის ფენები @@ -4299,3 +4315,17 @@ layerLabel Magenta მეწამული GPU Memory (MB, 0 = Automatic) GPU-ის მეხსიერება (მბ, 0 = ავტომატური) {name} was changed by another app after it was opened or saved here. {name} სხვა აპლიკაციამ შეცვალა მას შემდეგ, რაც აქ გაიხსნა ან შეინახა. Replace it with this document, or cancel and use File › Save As to keep both. ჩაანაცვლეთ ის ამ დოკუმენტით, ან გააუქმეთ და ორივეს შესანარჩუნებლად გამოიყენეთ ფაილი › შენახვა როგორც. + Pasting from other apps is unavailable სხვა აპლიკაციებიდან ჩასმა მიუწვდომელია + PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ამ სესიაში სისტემის გაცვლის ბუფერთან წვდომას ვერ ახერხებს. Wayland-ზე Flatpak-ის იზოლირებული გარემო მალავს პროტოკოლს, რომლითაც სხვა აპლიკაციების გამოსახულებები იკითხება. + To paste from other apps, start PhotoCraft under XWayland: `{command}` სხვა აპლიკაციებიდან ჩასასმელად გაუშვით PhotoCraft XWayland-ში: `{command}` + Nothing to paste: PhotoCraft can't read the system clipboard here ჩასასმელი არაფერია: PhotoCraft აქ სისტემის გაცვლის ბუფერს ვერ კითხულობს + Show file name in title ფაილის სახელის ჩვენება სათაურში + Memory Usage მეხსიერების გამოყენება + Per-document RAM limit (MB) RAM-ის ლიმიტი თითო დოკუმენტზე (მბ) + Installed RAM: {ram} MB; this limit: {percent}% კომპიუტერის RAM: {ram} მბ; ეს ლიმიტი: {percent}% + Limits each open document's pixels plus History. Oldest undo states are discarded when the limit is exceeded. ზღუდავს თითოეული ღია დოკუმენტის პიქსელებსა და ისტორიას ერთად. ლიმიტის გადაჭარბებისას დაბრუნების უძველესი მდგომარეობები იშლება. + Applies immediately when saved. Memory is not reserved in advance. შენახვისთანავე მოქმედებს. მეხსიერება წინასწარ არ რეზერვდება. + With GPU Memory set to Automatic, this also limits the GPU layer budget. That budget is capped at one quarter of installed RAM and, when known, three eighths of the graphics card's memory, with a 512 MB minimum. Raising this limit beyond those caps does not increase the GPU budget. როცა GPU-ის მეხსიერება ავტომატურზეა დაყენებული, ეს GPU-ზე შრეების ლიმიტსაც ზღუდავს. ის არ აღემატება კომპიუტერის RAM-ის მეოთხედს და, თუ ცნობილია, ვიდეობარათის მეხსიერების სამ მერვედს; მინიმუმი 512 მბ-ია. ამ ზღვრებს ზემოთ ამ ლიმიტის გაზრდა GPU-ის ლიმიტს არ ზრდის. + Use გამოყენება + In Front წინ + Behind უკან diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 74f2b595ee..a406ef7193 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -48,7 +48,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde 9 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the -other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,234 rows; 1,844 / 1,844 measured keys on 2026-10-14; +other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,263 rows; 1,844 / 1,844 measured keys on 2026-10-14; Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse 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 017/310] 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 3de6bc84a1..febbfdfa20 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 0000000000..89560756bb --- /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 ba7556901b..f50adcedcf 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 8ef8722c16..b186f30e54 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 0000000000..855959669c --- /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 afb8092cd2..68d003b4df 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 184e9ccce3..5fad8e5f53 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 58d6632ea3..974b100e29 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 05697f9e6a..f48f3853b9 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 4457a87dde..215f1cc7af 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 c4b53a20f8..8ede545b57 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 3eca518c27..d4d1dfd73b 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 51f6b1cf33..eb408bbc6a 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 018/310] 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 c3c5c3693c..a6d0c30eea 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 ed56e50023..854ac046ee 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 81d17c28c2..af6d373528 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 0000000000..5758b5d722 --- /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 57f8c5d7da..1bcee752f6 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 019/310] 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 ec76ff9f86..82da2154f4 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 020/310] 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 d4e42d83ed..6167239639 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 82da2154f4..76d28a83e5 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 021/310] 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 b620230a61..6bc9632b22 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 d2e535f3f6..f4a1b91074 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 a908df5eba..ad60a732fa 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 022/310] 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 16e98b8719..f7362d28b2 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 f4a1b91074..c25b5a07e4 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 023/310] 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 b5da81fede..f8c3dcb32f 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 136c367898..ddda92ff64 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 024/310] 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 268b0b6b43..51acf288b9 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 854ac046ee..2a2d965f08 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 af6d373528..851b785814 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 3ca10083b9..6ae862ce51 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 5758b5d722..c5581d3404 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 1bcee752f6..a89d3f6e99 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 025/310] 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 f317ebeca6..1540dc1a58 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 f2e795430c..4d71ed179a 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 b9ed130839..ced818bee2 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 60895bb097..492f8534e7 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 026/310] 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 4408766f1f..2c58f067c9 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 1540dc1a58..6c7a45c4d1 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 ced818bee2..1aa3034bc9 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 492f8534e7..95066675c0 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 9fd35b7e25..1aae0d548e 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 59d05d1e40..bebf3caa4f 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 027/310] 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 432ef67ca9..081ff7aef6 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 2746c1576e..bb5f8b4477 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 1aa3034bc9..d7156bdeaa 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 028/310] 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 081ff7aef6..c0cf7ed487 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 bb5f8b4477..1ab90bd33b 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 d7156bdeaa..6f56206bf9 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 afaeaeeb26c6c2b57424b66829f19ec21e1e285c Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 22 Oct 2026 21:56:02 +0400 Subject: [PATCH 029/310] i18n(ka): Georgian rows for the strings main added (bevel shading, contours, demosaic, paragraph direction, JPEG options) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 121 +++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 06e754ba51..fd197bab3c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -924,7 +924,6 @@ Contiguous მომიჯნავე Contrast კონტრასტი Control: მართვა: - Convert document's colors to the working space დოკუმენტის ფერების სამუშაო სივრცეში გარდაქმნა Convert to sRGB sRGB-ად გარდაქმნა Copies ასლები Copper სპილენძი @@ -1030,7 +1029,6 @@ Done მზადაა Dots წერტილები Drag a rectangle around the object ობიექტის გარშემო მართკუთხედი გაავლეთ - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. შეზღუდვის დასამატებლად გამოსახულებაზე გაავლეთ; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად — მარჯვენა დაწკაპუნება. Drag to move the active shape's path or the Work Path გადაათრიეთ აქტიური ფიგურის ტრაექტორიის ან სამუშაო ტრაექტორიის გადასაადგილებლად Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath დაწკაპუნება: საყრდენი წერტილის მონიშვნა · გადათრევა: საყრდენი წერტილის, სახელურის ან სეგმენტის გადაადგილება · {shift}-დაწკაპუნება: დამატება · {alt}-დაწკაპუნება: მთელი ქვეტრაექტორია Draw with the Pen tool (P) or make a work path from a selection. დახაზეთ კალმით (P) ან შექმენით სამუშაო ტრაექტორია მონიშნულიდან. @@ -1498,7 +1496,6 @@ Use Default ნაგულისხმევის გამოყენება Use Legacy ძველი ვერსიის გამოყენება Use Measurement Scale საზომი მასშტაბის გამოყენება - Use the embedded profile (instead of the working space) ჩაშენებული პროფილის გამოყენება (სამუშაო სივრცის ნაცვლად) Variables bind a layer's visibility, text or pixels to a named data slot. ცვლადები შრის ხილვადობას, ტექსტს ან პიქსელებს აკავშირებს მონაცემთა დასახელებულ ველთან. Vertical scale ვერტიკალური მასშტაბი Vibrance სიხალასე @@ -4188,7 +4185,6 @@ liquify Rate სიჩქარე Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. სამაგრის დასამატებლად დაწკაპეთ გამოსახულებაზე; სამაგრის გადასაადგილებლად გადაათრიეთ ის, გაბუნდოვნების შესაცვლელად კი — მისი რგოლი. Delete წაშლის მონიშნულ სამაგრს. The document “{name}” does not have an embedded {mode} profile. დოკუმენტს „{name}“ არ აქვს ჩაშენებული {mode} პროფილი. The document “{name}” has an embedded color profile that does not match the current {mode} working space. დოკუმენტს „{name}“ აქვს ჩაშენებული ფერის პროფილი, რომელიც მიმდინარე {mode} სამუშაო სივრცეს არ შეესაბამება. - Embedded: {embedded} ჩაშენებული: {embedded} Working: {working} სამუშაო: {working} Custom background color ფონის მორგებული ფერი Redrawing გადახატვა @@ -4329,3 +4325,120 @@ layerLabel Magenta მეწამული Use გამოყენება In Front წინ Behind უკან + Overprint ზედდაბეჭდვა + Current Brush Load ფუნჯზე აღებული საღებავი + Load Brush ფუნჯზე საღებავის აღება + Clean Brush ფუნჯის გაწმენდა + Load the brush after each stroke ყოველი შტრიხის შემდეგ ფუნჯზე საღებავის აღება + Clean the brush after each stroke ყოველი შტრიხის შემდეგ ფუნჯის გაწმენდა + Animation Options ანიმაციის პარამეტრები + Entire Document მთელი დოკუმენტი + Filter Options ფილტრის პარამეტრები + Layer Bounds შრის საზღვრები + Layers Panel Options შრეების პანელის პარამეტრები + New Artboard… ახალი სამუშაო დაფა… + New Group… ახალი ჯგუფი… + Prevent Auto-Nesting ავტომატური ჩადგმის აკრძალვა + Show Layers შრეების ჩვენება + Thumbnail Contents ესკიზის შიგთავსი + Thumbnail Size ესკიზის ზომა + Use Default Masks on Fill Layers შევსების შრეებზე ნაგულისხმევი ნიღბების გამოყენება + Expand New Effects ახალი ეფექტების გაშლა + Add “copy” to Copied Layers and Groups დაკოპირებულ შრეებსა და ჯგუფებზე „copy“-ის დამატება + Preview on canvas when reordering layers შრეების გადალაგებისას ტილოზე წინასწარი ნახვა + Show layer mask badges შრის ნიღბის ნიშნების ჩვენება + Blending Options ({filter}) შერევის პარამეტრები ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) შრის ეფექტებისა და ჭკვიანი ფილტრების დამალვა ({key}+დაწკაპუნება: ყველა შრე) + Show the layer's effects and Smart Filters ({key}-click: all layers) შრის ეფექტებისა და ჭკვიანი ფილტრების ჩვენება ({key}+დაწკაპუნება: ყველა შრე) + Wait for the current job to finish. დაელოდეთ მიმდინარე დავალების დასრულებას. + The document or Smart Filter changed. Reopen its dialog. დოკუმენტი ან ჭკვიანი ფილტრი შეიცვალა. ხელახლა გახსენით მისი დიალოგი. + Opacity must be between 0% and 100%. გაუმჭვირვალობა უნდა იყოს 0%-სა და 100%-ს შორის. + Dissolve is approximated: it blends without Photoshop's dither. „გაფანტვა“ მიახლოებითია: შერევა Photoshop-ის დიზერინგის გარეშე ხდება. + New Snapshot… ახალი სნეპშოტი… + History Options… ისტორიის პარამეტრები… + Paints from the History panel's source state ხატავს ისტორიის პანელის წყარო მდგომარეობიდან + Sets the source for the history brush ადგენს ისტორიის ფუნჯის წყაროს + Bicubic Automatic ბიკუბური, ავტომატური + Bicubic Smoother ბიკუბური, უფრო გლუვი + Bicubic Sharper ბიკუბური, უფრო მკვეთრი + 50% Threshold 50%-იანი ზღვარი + Pattern Dither პატერნული დიზერინგი + Diffusion Dither დიფუზიური დიზერინგი + Halftone Screen ნახევარტონური რასტრი + Custom Pattern მორგებული პატერნი + Frequency: სიხშირე: + Lines/Inch ხაზი/დუიმი + Lines/Centimeter ხაზი/სანტიმეტრი + Ellipse ელიფსი + DDS Options DDS-ის პარამეტრები + Generate Mipmaps მიპმაპების გენერაცია + There is no color table to save. შესანახი ფერთა ცხრილი არ არის. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file}-ში შეინახა ფერი: {n} + GPU adapter GPU ადაპტერი + +# Strings added upstream (sync 2026-10-22) + Mask Options… ნიღბის პარამეტრები… + Layer Mask Display Options… შრის ნიღბის ჩვენების პარამეტრები… + Create warped text დეფორმირებული ტექსტის შექმნა + Override Tilt X X-დახრის უგულებელყოფა + Override Tilt Y Y-დახრის უგულებელყოფა + Rename Path… ტრაექტორიის სახელის შეცვლა… + LUT folder LUT-ების საქაღალდე + Retry GPU tries the graphics processor again now. „GPU-ის ხელახლა ცდა“ გრაფიკული პროცესორის გამოყენებას ახლავე ხელახლა სცდის. + GPU acceleration resumed. GPU-აჩქარება აღდგა. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. შეზღუდვის დასამატებლად გამოსახულებაზე გაავლეთ; Shift — ჰორიზონტალური/ვერტიკალური; შეზღუდვის შესაცვლელად ან წასაშლელად — მასზე მარჯვენა დაწკაპუნება. + JPEG Options JPEG-ის პარამეტრები + Image Options გამოსახულების პარამეტრები + Format Options ფორმატის პარამეტრები + Baseline ("Standard") საბაზისო („სტანდარტული“) + Baseline Optimized საბაზისო ოპტიმიზებული + Copyright Notice საავტორო უფლების შენიშვნა + Document Title დოკუმენტის სათაური + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels აქტიურ შრეს აქ პიქსელები არ აქვს — ჩართეთ „ყველა შრიდან ნიმუშის აღება“ ან აირჩიეთ შრე, რომელსაც პიქსელები აქვს + Load Contours… კონტურების ჩატვირთვა… + Replace Contours… კონტურების ჩანაცვლება… + Save Contours… კონტურების შენახვა… + Delete Contour კონტურის წაშლა + Loaded contours from {file} {file}-დან ჩაიტვირთა კონტურები + Saved contours to {file} {file}-ში შეინახა კონტურები + New Action… ახალი მოქმედება… + Start Recording ჩაწერის დაწყება + Stop Recording ჩაწერის შეჩერება + Highlight Mode ღია ტონების რეჟიმი + Highlight Color ღია ტონების ფერი + Highlight Opacity ღია ტონების გაუმჭვირვალობა + Shadow Mode ჩრდილის რეჟიმი + Shadow Color ჩრდილის ფერი + Shadow Opacity ჩრდილის გაუმჭვირვალობა + Do not append "copy" to filename when saving a copy ასლის შენახვისას ფაილის სახელს „copy“ არ დაემატოს + Graphics memory: {size} გრაფიკული მეხსიერება: {size} + {path}: the path is not valid Unicode; save under another name. {path}: გზა არ არის სწორი Unicode; შეინახეთ სხვა სახელით. + Demosaic დემოზაიკა + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. როგორ ივსება კამერის ფერადი ფილტრის მოზაიკა. გამოიყენება ფაილის გახსნისას; წინასწარი ნახვა აჩვენებს მეთოდს, რომლითაც ფაილი გაიხსნა. + AHD (adaptive homogeneity) AHD (ადაპტური ერთგვაროვნება) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: ყველაზე სუფთა კიდეები და ყველაზე ნაკლები ფერადი შარავანდი, ყველაზე ნელი. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. გრადიენტით კორექტირებული ინტერპოლაცია: მკვეთრი და სწრაფი, წვრილ დეტალებზე მცირე ფერადი შარავანდით. + The fastest and the softest, with colour fringing on fine detail. ყველაზე სწრაფი და ყველაზე რბილი, წვრილ დეტალებზე ფერადი შარავანდით. + “{name}” already exists. Replace it? „{name}“ უკვე არსებობს. ჩავანაცვლოთ? + The name in the save dialog was “{name}”. შენახვის დიალოგში სახელი იყო „{name}“. + Move Up აწევა + Move Down ჩამოწევა + Previous Brush წინა ფუნჯი + Next Brush შემდეგი ფუნჯი + First Brush პირველი ფუნჯი + Last Brush ბოლო ფუნჯი + Preview size გადახედვის ზომა + Confirm or cancel the open action step dialog first ჯერ დაადასტურეთ ან გააუქმეთ მოქმედების ნაბიჯის გახსნილი დიალოგი + Pro Medium Gray Pro საშუალო ნაცრისფერი + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ღია ნაცრისფერი + Pro Light Pro ღია + Resize Windows to Fit ფანჯრების ზომის მორგება + EXR EXR + Automatic paragraph direction აბზაცის ავტომატური მიმართულება + Left-to-right paragraph direction აბზაცის მიმართულება მარცხნიდან მარჯვნივ + Right-to-left paragraph direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ + Not rendered: this adjustment ({key}) is kept as imported. არ არის დარენდერებული: ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით. + Print preview image ამობეჭდვამდე ნახვის გამოსახულება From c78263ea77079ed9bdc79a26947cf7fe8577b2c9 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 22 Oct 2026 23:27:39 +0400 Subject: [PATCH 030/310] i18n(ka): review fixes from supersfera-online; Shear and Relative Colorimetric aligned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 21 rows from their review (typo მინიატიურების → მინიატურების, Remove Tool as a tool name, Fill Content, Display Text, the Leading tooltip's case, Displace, Mac Crisp, collapse, place, overrides, the brush preset warning plural, source files, …). Shear and Shear… become დახრა as in VectorCraft and DesignCraft. Co-authored-by: supersfera <223132313+supersfera-online@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 44 +++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fd197bab3c..807e48ade4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -421,7 +421,7 @@ Layer Edges შრის კიდეები Isolate Layers შრეების იზოლირება Layer Content Options… შრის შიგთავსის პარამეტრები… - Fill Content შიგთავსის შევსება + Fill Content შევსების შიგთავსი Deselect Layers შრეების მონიშვნის მოხსნა Find Layers შრეების ძებნა Layer Comps შრეების კომპოზიციები @@ -464,8 +464,8 @@ Convert to Point Text წერტილოვან ტექსტად გარდაქმნა Convert to Paragraph Text აბზაცის ტექსტად გარდაქმნა Default Features ნაგულისხმევი შესაძლებლობები - East Asian Features აღმოსავლეთ აზიური შესაძლებლობები - Middle Eastern Features ახლო აღმოსავლური შესაძლებლობები + East Asian Features აღმოსავლეთაზიური შესაძლებლობები + Middle Eastern Features ახლოაღმოსავლური შესაძლებლობები Middle Eastern & South Asian Composer აწყობა ახლო აღმოსავლეთისა და სამხრეთ აზიის დამწერლობებისთვის Update All Text Layers ყველა ტექსტური შრის განახლება Replace All Missing Fonts ყველა ნაკლული შრიფტის ჩანაცვლება @@ -531,7 +531,7 @@ Pinch… შეკუმშვა… Polar Coordinates… პოლარული კოორდინატები… Ripple… ტალღოვნება… - Shear… ძვრა… + Shear… დახრა… Spherize… სფერიზება… Twirl… დაგრეხა… Wave… ტალღა… @@ -1019,7 +1019,7 @@ Discord Discord Dismiss დახურვა Display P3 Display P3 - Display Text საჩვენებელი ტექსტი + Display Text ტექსტის ჩვენება Dither დიზერინგი Divide… გაყოფა… Document დოკუმენტი @@ -1157,7 +1157,7 @@ Last Document State დოკუმენტის ბოლო მდგომარეობა Layout განლაგება Leading სტრიქონთა დაშორება - Leading (set to the font size × auto-leading when Auto) სტრიქონთა დაშორება (Auto-ზე უდრის შრიფტის ზომა × ავტომატური დაშორება) + Leading (set to the font size × auto-leading when Auto) სტრიქონთა დაშორება (ავტომატურ რეჟიმში უდრის შრიფტის ზომას × ავტომატური დაშორება) Leave as is (don't color manage) დატოვება როგორც არის (ფერების მართვის გარეშე) Left align text ტექსტის მარცხნივ სწორება Length: სიგრძე: @@ -1335,12 +1335,12 @@ Registration Marks შეთავსების ნიშნები Regular ჩვეულებრივი Relative ფარდობითი - Relative Colorimetric შეფარდებით კოლორიმეტრული + Relative Colorimetric ფარდობითი კოლორიმეტრიული Remove მოშორება Remove All Pins ყველა სამაგრის მოშორება Remove Background ფონის მოშორება Remove Planes სიბრტყეების მოშორება - Remove Tool ხელსაწყოს მოშორება + Remove Tool მოშორების ხელსაწყო Remove stop გადასვლის წერტილის წაშლა Remove variable ცვლადის მოშორება Rename სახელის შეცვლა @@ -1796,7 +1796,7 @@ Apply auto tone ავტოტონის გამოყენება Ask კითხვა Ask before saving layered tiff შრეებიანი TIFF-ის შენახვამდე კითხვა - Auto collapse icon panels ხატულების პანელების ავტომატური აკეცვა + Auto collapse icon panels ხატულების პანელების ავტომატური ჩაკეცვა Auto show hidden panels დამალული პანელების ავტომატური ჩვენება Auto show home screen საწყისი ეკრანის ავტომატური ჩვენება Autosave ავტოშენახვა @@ -1910,7 +1910,7 @@ Recent fonts ბოლოდროინდელი შრიფტები Recover on launch გაშვებისას აღდგენა Remember workspace changes სამუშაო სივრცის ცვლილებების დამახსოვრება - Resize image during place ჩასმისას გამოსახულების ზომის შეცვლა + Resize image during place განთავსებისას გამოსახულების ზომის შეცვლა Rotate view with trackpad ხედის შემობრუნება სენსორული პანელით Same folder იგივე საქაღალდე Save in background ფონურ რეჟიმში შენახვა @@ -2254,7 +2254,7 @@ Baseline Shift საბაზისო ხაზის წანაცვლება Mac LCD Mac LCD Mac Sharp Mac მკვეთრი - Mac Crisp Mac ხასხასა + Mac Crisp Mac მკაფიო Mac Strong Mac ძლიერი Mac Smooth Mac გლუვი Superscript ზედა ინდექსი @@ -2537,7 +2537,7 @@ Airbrush: keeps painting while the pointer rests. აეროგრაფი: ხატვა გრძელდება, სანამ მაჩვენებელი ერთ ადგილზე დგას. Click the first point or press ↵ to close · Esc cancels დაწკაპეთ პირველ წერტილზე ან დააჭირეთ ↵-ს დასახურად · Esc — გაუქმება Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove დაწკაპეთ წერტილის დასამატებლად · მოსაშორებლად გადაათრიეთ გარეთ, ⌘/Ctrl+დაწკაპუნება ან Delete - Click to apply, ⌥-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, ⌥+დაწკაპუნება — ცვლილებების გასუფთავება, ორმაგი დაწკაპუნება — სტილის პარამეტრები + Click to apply, ⌥-click to clear overrides, double-click for Style Options დაწკაპეთ გამოსაყენებლად, ⌥+დაწკაპუნება — გადაფარვების მოხსნა, ორმაგი დაწკაპუნება — სტილის პარამეტრები Click to zoom in · ⌥-click to zoom out დაწკაპეთ გასადიდებლად · ⌥+დაწკაპუნება — დასაპატარავებლად Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel დაწკაპუნება: კუთხე · გადათრევა: გლუვი · პირველ წერტილზე დაწკაპუნება: დახურვა · ↩ დასრულება · Esc გაუქმება Commit Perspective Warp (↩) პერსპექტიული დეფორმაციის დადასტურება (↩) @@ -2919,7 +2919,7 @@ fontWeight Italic დახრილი Remove pair წყვილის წაშლა Dash offset ტირის წანაცვლება Open paths use centered strokes ღია ტრაექტორიების კონტური ცენტრირებულია - Use finite lengths and a positive pattern total გამოიყენეთ სასრული სიგრძეები და დადებითი ჯამური ნიმუში + Use finite lengths and a positive pattern total გამოიყენეთ სასრული სიგრძეები და ნიმუშის დადებითი ჯამური სიგრძე No subject found — try Object Selection or the Lasso ობიექტი ვერ მოიძებნა — სცადეთ ობიექტის მონიშვნა ან ლასო Quick Selection found nothing there სწრაფმა მონიშვნამ იქ ვერაფერი იპოვა Object Selection found nothing there — try a tighter rectangle ობიექტის მონიშვნამ იქ ვერაფერი იპოვა — სცადეთ უფრო ვიწრო მართკუთხედი @@ -3014,7 +3014,7 @@ cameraData Lens ობიექტივი # Select and Mask Refine Edge Brush (#2972) Refine Edge Brush (R) კიდის დაზუსტების ფუნჯი (R) Expand Detection Area აღმოჩენის არის გაფართოება - Restore Original Edge ორიგინალი კიდის აღდგენა + Restore Original Edge საწყისი კიდის აღდგენა Paint over hair or fur on the image; Alt paints the other mode გამოსახულებაზე თმა ან ბეწვი გადაღებეთ; Alt მეორე რეჟიმით ხატავს Clear Brush Strokes ფუნჯის მონასმების გასუფთავება Show rulers სახაზავების ჩვენება @@ -3639,7 +3639,7 @@ layerLabel No Color ფერის გარეშე Defringe კიდის შარავანდის მოცილება Diffuse დიფუზია Diffuse Glow გაბნეული ნათება - Displace ჩანაცვლება რუკით + Displace წანაცვლება Do you want to save you made to “{name}” before quitting? გსურთ „{name}“-ში შეტანილი ცვლილებების შენახვა გასვლამდე? Dust & Scratches მტვერი და ნაკაწრები Export Contents შიგთავსის ექსპორტი @@ -3700,7 +3700,7 @@ layerLabel No Color ფერის გარეშე Pinch მოჭყლეტა Place Embedded ჩაშენებულის განთავსება Place Linked დაკავშირებულის განთავსება - Place Scale Marker მასშტაბის ნიშნულის განთავსება + Place Scale Marker მასშტაბის მარკერის განთავსება Plastic Wrap პლასტიკური შეფუთვა Pointillize პუანტილიზმი Polar Coordinates პოლარული კოორდინატები @@ -3731,7 +3731,7 @@ layerLabel No Color ფერის გარეშე Set Measurement Scale გაზომვის მასშტაბის დაყენება Shadows/Highlights ჩრდილები/ღია ტონები Shape Blur ფიგურით გაბუნდოვნება - Shear ცვლა + Shear დახრა Smart Blur ჭკვიანი გაბუნდოვნება Smart Sharpen ჭკვიანი გამკვეთრება Smudge Stick გამღაბნელი ჩხირი @@ -3853,7 +3853,7 @@ curves Smooth გაგლუვება @plural {n} sample point removed|{n} sample points removed მოშორებულია სინჯის წერტილი: {n} @plural {n} slice|{n} slices ნაჭერი: {n} @plural {n} step|{n} steps ნაბიჯი: {n} -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ფუნჯის პრესეტის კიდევ ერთი გაფრთხილება: +{n}) +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ფუნჯის პრესეტის დამატებითი გაფრთხილება: +{n}) @plural {what} with {n} warning|{what} with {n} warnings {what}, გაფრთხილება: {n} Duplicate Channel არხის დუბლირება Delete Channel არხის წაშლა @@ -4093,7 +4093,7 @@ shape Appearance იერსახე Add to Dictionary ლექსიკონში დამატება All shortcuts reset to Photoshop defaults. ყველა მალსახმობი დაბრუნდა Photoshop-ის ნაგულისხმევზე. Allow Continue გაგრძელების დაშვება - Attempt to Automatically Align Source Images წყარო გამოსახულებების ავტომატური სწორების მცდელობა + Attempt to Automatically Align Source Images წყაროს გამოსახულებების ავტომატური სწორების მცდელობა Auto Add/Delete ავტომატური დამატება/წაშლა # Native Windows printing qualification @@ -4158,7 +4158,7 @@ shape Appearance იერსახე Solidity: სიმკვრივე: Sort By Name სახელის მიხედვით დალაგება Source Channel წყაროს არხი - Source Files წყარო ფაილები + Source Files წყაროს ფაილები Source Invert წყაროს ინვერსია Source1 Channel წყარო 1-ის არხი Source1 Invert წყარო 1-ის ინვერსია @@ -4248,8 +4248,8 @@ layerLabel Magenta მეწამული Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ სამაგრი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. Drag the handles on the outline to resize and turn it; the ring sets the blur angle. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად; რგოლი გაბუნდოვნების კუთხეს აყენებს. Add Shortcut მალსახმობის დამატება - Clip thumbnails to layer bounds მინიატიურების შრის საზღვრებით მოჭრა - Clip Thumbnails to Layer Bounds მინიატიურების შრის საზღვრებით მოჭრა + Clip thumbnails to layer bounds მინიატურების შრის საზღვრებით მოჭრა + Clip Thumbnails to Layer Bounds მინიატურების შრის საზღვრებით მოჭრა Import Brushes… (.abr or image) ფუნჯების იმპორტი… (.abr ან გამოსახულება) Import Brush from Image… ფუნჯის იმპორტი გამოსახულებიდან… Bundle Arrow-Key Nudges ისრებით წანაცვლებების გაერთიანება From 796fc8fb50f02a4836b218452b417393051075ec Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Thu, 22 Oct 2026 23:33:13 +0400 Subject: [PATCH 031/310] =?UTF-8?q?i18n(ka):=20thumbnails=20are=20?= =?UTF-8?q?=E1=83=94=E1=83=A1=E1=83=99=E1=83=98=E1=83=96=E1=83=94=E1=83=91?= =?UTF-8?q?=E1=83=98=20(glossary),=20not=20=E1=83=9B=E1=83=98=E1=83=9C?= =?UTF-8?q?=E1=83=98=E1=83=90=E1=83=A2=E1=83=A3=E1=83=A0=E1=83=94=E1=83=91?= =?UTF-8?q?=E1=83=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 807e48ade4..62e0e7efc3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4248,8 +4248,8 @@ layerLabel Magenta მეწამული Drag the pin to move the band, or its ring to change its blur. Drag the solid lines to widen the sharp band, the dashed lines to set the transition, and the dots to turn it. გადაათრიეთ სამაგრი ზოლის გადასაადგილებლად, ან მისი რგოლი — გაბუნდოვნების შესაცვლელად. გადაათრიეთ უწყვეტი ხაზები მკვეთრი ზოლის გასაფართოებლად, წყვეტილი ხაზები — გარდამავალი არის დასაყენებლად, წერტილები კი — შესაბრუნებლად. Drag the handles on the outline to resize and turn it; the ring sets the blur angle. გადაათრიეთ კონტურის სახელურები ზომის შესაცვლელად და შესაბრუნებლად; რგოლი გაბუნდოვნების კუთხეს აყენებს. Add Shortcut მალსახმობის დამატება - Clip thumbnails to layer bounds მინიატურების შრის საზღვრებით მოჭრა - Clip Thumbnails to Layer Bounds მინიატურების შრის საზღვრებით მოჭრა + Clip thumbnails to layer bounds ესკიზების შრის საზღვრებით მოჭრა + Clip Thumbnails to Layer Bounds ესკიზების შრის საზღვრებით მოჭრა Import Brushes… (.abr or image) ფუნჯების იმპორტი… (.abr ან გამოსახულება) Import Brush from Image… ფუნჯის იმპორტი გამოსახულებიდან… Bundle Arrow-Key Nudges ისრებით წანაცვლებების გაერთიანება 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 032/310] 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 ddda92ff64..a6072003fb 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 033/310] 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 6e84b9847e..5895456bcd 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 76d28a83e5..06777140af 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 1321f8a0b37797489d9e08eae8c1975142c5626b Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Fri, 23 Oct 2026 02:26:12 +0400 Subject: [PATCH 034/310] Move fonts, samples and casing to follow-up PRs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 1 - ROADMAP.md | 2 +- apps/photocraft-web/src/web.rs | 2 +- crates/text/src/scripts.rs | 2 +- crates/text/src/served.rs | 8 ++------ crates/ui-egui/src/i18n/LICENSE-translations.txt | 5 +---- crates/ui-egui/src/i18n/live_tests.rs | 1 - crates/ui-egui/src/liquify_ui.rs | 2 +- packaging/macos/Info.plist.in | 1 - xtask/src/i18n_coverage.rs | 1 - 13 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 987215db73..b06e15449d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -278,7 +278,7 @@ jobs: uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: storytold/craft-fonts - ref: a71c20e30af613287c504f7e685ca8fad77752bc + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Test with craft-fonts diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 02c7387448..56d1ad0831 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -30,7 +30,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 cargo-build-extra-args: "--features heif" # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at # once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db81f742b2..bfdca9e3e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: photocraft app-title: PhotoCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d310d4bf61..d8244b4fec 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -15,7 +15,6 @@ next to them. | `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | -| `crates/ui-egui/src/i18n/ka.tsv` | Georgian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/nl.tsv` | Dutch UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/pl.tsv` | Polish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | diff --git a/ROADMAP.md b/ROADMAP.md index 5e6fa08a56..829ff75af8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo | Korean | `ko` | 100% | partial | | Vietnamese | `vi` | 0% | none | -Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian, Georgian (9). +Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8). ## Upcoming diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 1f023b717e..fc17612908 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -374,7 +374,7 @@ impl eframe::App for WebShell { self.presets.retry(); ui.ctx().request_repaint(); } - ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", i18n::t(&error))])); + ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)])); }); ui.label(if self.database.is_some() { i18n::t("Keep this tab open to retain your brushes.") diff --git a/crates/text/src/scripts.rs b/crates/text/src/scripts.rs index 3f8bfd1cb1..b6e0aae74f 100644 --- a/crates/text/src/scripts.rs +++ b/crates/text/src/scripts.rs @@ -106,7 +106,7 @@ impl Script { ], Hebrew => &["notosanshebrew", "notoserifhebrew", "segoeui", "arialhb", "sfhebrew", "dejavusans", "arial", "freesans", "culmus"], Armenian => &["notosansarmenian", "notoserifarmenian", "segoeui", "sylfaen", "mshtakan", "dejavusans", "freesans"], - Georgian => &["notosansgeorgian", "notoserifgeorgian", "segoeui", "sylfaen", "sfgeorgian", "dejavusans", "freesans"], + Georgian => &["notosansgeorgian", "notoserifgeorgian", "segoeui", "sylfaen", "dejavusans", "freesans"], Ethiopic => &["notosansethiopic", "notoserifethiopic", "ebrima", "kefa", "abyssinicasil"], Devanagari => &[ "notosansdevanagari", diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 3d2206fd87..0198aea97a 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -11,7 +11,7 @@ //! //! Script fallback: the manifest's `scripts` field says which scripts a font is for. The first //! served family listed for a script the bundled fonts don't cover (Arabic, Hebrew, Devanagari, -//! Thai, Khmer, Lao, Burmese, Georgian, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it +//! Thai, Khmer, Lao, Burmese, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it //! ([`request_for_text`], from layout), the font database puts it in the fallback stack once it //! arrives, and the web shell asks up front for the scripts of the browser's languages //! ([`request_for_languages`]). The manifest's SHA-256 becomes the fetch's Subresource Integrity @@ -63,7 +63,7 @@ fn is_relative_path(p: &str) -> bool { /// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers /// Latin, Greek and Cyrillic) lack, as ISO 15924 codes. -const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Khmr", "Laoo", "Mymr", "Geor", "Jpan", "Hans", "Hant", "Kore"]; +const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Khmr", "Laoo", "Mymr", "Jpan", "Hans", "Hant", "Kore"]; /// The script fallbacks in `fonts`: for each script in [`FALLBACK_SCRIPTS`], the first family in /// manifest order that lists it, as `(script, family)` in order of first appearance. @@ -99,7 +99,6 @@ pub fn script_of(c: char, order: &[crate::cjk::CjkScript; 4]) -> Option<&'static 0x1780..=0x17FF | 0x19E0..=0x19FF => "Khmr", 0x0E80..=0x0EFF => "Laoo", 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F => "Mymr", - 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => "Geor", _ => return None, }) } @@ -118,7 +117,6 @@ pub fn language_script(tag: &str) -> Option<&'static str> { "km" => "Khmr", "lo" => "Laoo", "my" => "Mymr", - "ka" => "Geor", "ja" => "Jpan", "ko" => "Kore", "zh" if rest.iter().any(|r| matches!(*r, "hant" | "tw" | "hk" | "mo")) => "Hant", @@ -413,7 +411,6 @@ mod tests { ('ລ', Some("Laoo")), ('မ', Some("Mymr")), ('ၡ', Some("Mymr")), - ('ა', Some("Geor")), ('あ', Some("Jpan")), ('한', Some("Kore")), ] { @@ -436,7 +433,6 @@ mod tests { ("km-KH", Some("Khmr")), ("lo", Some("Laoo")), ("my-MM", Some("Mymr")), - ("ka-GE", Some("Geor")), ("ja-JP", Some("Jpan")), ("zh-CN", Some("Hans")), ("zh-TW", Some("Hant")), diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index a87fc0ac02..0e9c354446 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,5 +1,5 @@ PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, -Turkish, Hungarian, Serbian, Russian and Georgian UI translation catalogs are licensed under MIT OR +Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. @@ -40,6 +40,3 @@ PhotoCraft's English labels; no proprietary translation resources were used. Russian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. - -Georgian translations: PhotoCraft contributors, original translations of -PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index c1880bec55..e2150247b9 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -28,7 +28,6 @@ fn requested_languages_resolve_regional_preferences() { ("id-ID", "id"), ("vi-VN", "vi"), ("sr-RS", "sr"), - ("ka-GE", "ka"), ] { assert_eq!(Lang::from_pref(tag), lang(code), "{tag}"); } diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 72b5ed3748..cf1c932518 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -949,7 +949,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::slider_row(ui, tl!("Size"), &mut d.opts.size, 1.0..=3000.0, "", None); widgets::slider_row(ui, tl!("Density"), &mut d.opts.density, 0.0..=100.0, "", None); widgets::slider_row(ui, tl!("Pressure"), &mut d.opts.pressure, 0.0..=100.0, "", None); - widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "liquify", "Rate"), &mut d.opts.rate, 0.0..=100.0, "", None); + widgets::slider_row(ui, tl!("Rate"), &mut d.opts.rate, 0.0..=100.0, "", None); widgets::hairline(ui); widgets::section_label(ui, tl!("Brush Reconstruct Options")); widgets::slider_row(ui, tl!("Amount"), &mut d.opts.reconstruct_amount, 0.0..=100.0, "%", None); diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 2ad04449fc..b3cde0ea1c 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -31,7 +31,6 @@ hu tr vi - ka CFBundleDisplayName PhotoCraft diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 1eb5ac768f..ee337c0b9b 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -583,7 +583,6 @@ mod tests { ("tr", "tr.tsv"), ("vi", "vi.tsv"), ("sr", "sr.tsv"), - ("ka", "ka.tsv"), ] { let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); let text = fs::read_to_string(&catalog_path).unwrap_or_default(); From f26bd3e2570d31c3fed00b10cd99068e64dfe644 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 24 Oct 2026 02:57:09 +0200 Subject: [PATCH 035/310] 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 09f8c61ab9..862720c536 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 60ba6f8729..573d1e7ec2 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 06777140af..5fd82abeb5 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 3d0ec178c3144760cfa3d877f0ecbbb399c1a3f9 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 24 Oct 2026 15:16:27 +0400 Subject: [PATCH 036/310] i18n(ka): translate main's new strings (update checks, Color panel modes, .asl styles, damaged PSD prompt) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 43 ++++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 62e0e7efc3..808b1bf5c6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4442,3 +4442,46 @@ layerLabel Magenta მეწამული Right-to-left paragraph direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Not rendered: this adjustment ({key}) is kept as imported. არ არის დარენდერებული: ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით. Print preview image ამობეჭდვამდე ნახვის გამოსახულება +# Update checks (Help menu and startup) + Automatically Check for Updates განახლებების ავტომატური შემოწმება + Check for Updates… განახლებების შემოწმება… + Checking for updates… მიმდინარეობს განახლებების შემოწმება… + Check Again ხელახლა შემოწმება + Could not check for updates. განახლებების შემოწმება ვერ მოხერხდა. + Installed version: {version} დაინსტალირებული ვერსია: {version} + Open Download Page ჩამოტვირთვის გვერდის გახსნა + Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ ინსტალერი თქვენი სისტემისთვის. + PhotoCraft {version} is available. ხელმისაწვდომია PhotoCraft {version}. + PhotoCraft is up to date. PhotoCraft განახლებულია. + PhotoCraft Updates PhotoCraft-ის განახლებები + Updates have not been checked yet. განახლებები ჯერ არ შემოწმებულა. +# Color panel menu: slider modes, ramps and copy + RGB Sliders RGB სლაიდერები + HSB Sliders HSB სლაიდერები + CMYK Sliders CMYK სლაიდერები + Lab Sliders Lab სლაიდერები + Web Color Sliders ვებ-ფერების სლაიდერები + Hue Cube ფერის ტონის კუბი + Brightness Cube სიკაშკაშის კუბი + Color Wheel ფერთა წრე + RGB Spectrum RGB სპექტრი + CMYK Spectrum CMYK სპექტრი + Grayscale Ramp ნაცრისფერი ტონების შკალა + Current Colors მიმდინარე ფერები + Make Ramp Web Safe შკალაში მხოლოდ ვებ-ფერები + Copy Color as HTML ფერის კოპირება HTML-ის სახით + Copy Color's Hex Code ფერის Hex კოდის კოპირება +# Styles panel: .asl style libraries + Import Styles… სტილების იმპორტი… + Export Styles… სტილების ექსპორტი… + Export Selected Styles… მონიშნული სტილების ექსპორტი… + {file} is not a style library (.asl) {file} არ არის სტილების ბიბლიოთეკა (.asl) +@plural Exported {n} style to {file}|Exported {n} styles to {file} სტილი: {n} ექსპორტირებულია {file}-ში +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file}-დან იმპორტირებულია სტილი: {n} +# Damaged PSD/PSB prompt + Damaged Photoshop document დაზიანებული Photoshop-ის დოკუმენტი + The document “{name}” may be damaged. დოკუმენტი „{name}“ შესაძლოა დაზიანებული იყოს. + Some layers, pixels or masks could not be read. Do you want to continue opening it? ზოგიერთი შრის, პიქსელის ან ნიღბის წაკითხვა ვერ მოხერხდა. გსურთ, გააგრძელოთ გახსნა? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ, ორიგინალზე გადაწერით შენახვამ შეიძლება წაუკითხავი მონაცემები სამუდამოდ დაკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. +# Select and Mask: the workspace's edits (select.refineEdge) + Edits ცვლილებები diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99d478052f..2038067ff0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -51,7 +51,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the -other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,374 rows; every measured key on 2026-10-22; +other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,412 rows; every measured key on 2026-10-24; Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse From 3136b11eb8a87d350282d4448648a9a9b36ce32a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 24 Oct 2026 15:52:06 +0400 Subject: [PATCH 037/310] Georgian draws on its own: SF Georgian fallback on macOS, Geor served fallback on the web The same scripts.rs/served.rs/tests.rs lines as #8812, so either merges first; the craft-fonts pin stays in #8812. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/scripts.rs | 2 +- crates/text/src/served.rs | 8 ++++++-- crates/text/src/tests.rs | 7 ++++--- docs/localization-parity.md | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/text/src/scripts.rs b/crates/text/src/scripts.rs index b6e0aae74f..3f8bfd1cb1 100644 --- a/crates/text/src/scripts.rs +++ b/crates/text/src/scripts.rs @@ -106,7 +106,7 @@ impl Script { ], Hebrew => &["notosanshebrew", "notoserifhebrew", "segoeui", "arialhb", "sfhebrew", "dejavusans", "arial", "freesans", "culmus"], Armenian => &["notosansarmenian", "notoserifarmenian", "segoeui", "sylfaen", "mshtakan", "dejavusans", "freesans"], - Georgian => &["notosansgeorgian", "notoserifgeorgian", "segoeui", "sylfaen", "dejavusans", "freesans"], + Georgian => &["notosansgeorgian", "notoserifgeorgian", "segoeui", "sylfaen", "sfgeorgian", "dejavusans", "freesans"], Ethiopic => &["notosansethiopic", "notoserifethiopic", "ebrima", "kefa", "abyssinicasil"], Devanagari => &[ "notosansdevanagari", diff --git a/crates/text/src/served.rs b/crates/text/src/served.rs index 69897329b7..973a4a6897 100644 --- a/crates/text/src/served.rs +++ b/crates/text/src/served.rs @@ -11,7 +11,7 @@ //! //! Script fallback: the manifest's `scripts` field says which scripts a font is for. The first //! served family listed for a script the bundled fonts don't cover (Arabic, Hebrew, Devanagari, -//! Thai, Khmer, Lao, Burmese, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it +//! Thai, Khmer, Lao, Burmese, Georgian, Japanese, Chinese, Korean) is that script's fallback: text in that script asks for it //! ([`request_for_text`], from layout), the font database puts it in the fallback stack once it //! arrives, and the web shell asks up front for the scripts of the browser's languages //! ([`request_for_languages`]). The manifest's SHA-256 becomes the fetch's Subresource Integrity @@ -98,7 +98,7 @@ pub fn is_relative_path(p: &str) -> bool { /// The scripts a served font can be the fallback for: those the bundled fonts (Inter, which covers /// Latin, Greek and Cyrillic) lack, as ISO 15924 codes. -const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Khmr", "Laoo", "Mymr", "Jpan", "Hans", "Hant", "Kore"]; +const FALLBACK_SCRIPTS: &[&str] = &["Arab", "Hebr", "Deva", "Thai", "Khmr", "Laoo", "Mymr", "Geor", "Jpan", "Hans", "Hant", "Kore"]; /// The script fallbacks in `fonts`: for each script in [`FALLBACK_SCRIPTS`], the first family in /// manifest order that lists it, as `(script, family)` in order of first appearance. @@ -134,6 +134,7 @@ pub fn script_of(c: char, order: &[crate::cjk::CjkScript; 4]) -> Option<&'static 0x1780..=0x17FF | 0x19E0..=0x19FF => "Khmr", 0x0E80..=0x0EFF => "Laoo", 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F => "Mymr", + 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => "Geor", _ => return None, }) } @@ -152,6 +153,7 @@ pub fn language_script(tag: &str) -> Option<&'static str> { "km" => "Khmr", "lo" => "Laoo", "my" => "Mymr", + "ka" => "Geor", "ja" => "Jpan", "ko" => "Kore", "zh" if rest.iter().any(|r| matches!(*r, "hant" | "tw" | "hk" | "mo")) => "Hant", @@ -469,6 +471,7 @@ mod tests { ('ລ', Some("Laoo")), ('မ', Some("Mymr")), ('ၡ', Some("Mymr")), + ('ა', Some("Geor")), ('あ', Some("Jpan")), ('한', Some("Kore")), ] { @@ -491,6 +494,7 @@ mod tests { ("km-KH", Some("Khmr")), ("lo", Some("Laoo")), ("my-MM", Some("Mymr")), + ("ka-GE", Some("Geor")), ("ja-JP", Some("Jpan")), ("zh-CN", Some("Hans")), ("zh-TW", Some("Hant")), diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4fe9709942..ccb8739424 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2057,12 +2057,13 @@ fn craft_fonts_cover_japanese_without_system_fonts() { assert!(craft_family_of(&l).is_empty()); } -/// Chinese, Arabic and Thai set in Inter fall back to the craft font for their script when the -/// build has one (Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai in newer craft-fonts). +/// Chinese, Arabic, Thai and Georgian set in Inter fall back to the craft font for their script when +/// the build has one (Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai and Noto Sans Georgian in +/// newer craft-fonts). #[test] fn craft_fonts_cover_their_other_scripts_without_system_fonts() { let mut e = TextEngine::new(); - for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย")] { + for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย"), ("Geor", "ქართული შრე")] { if !crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script)) { eprintln!("skipping {script}: built without a craft font for it (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); continue; diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 2038067ff0..5859192eb7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -52,7 +52,7 @@ Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic lab other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,412 rows; every measured key on 2026-10-24; -Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, DejaVu Sans). Turkish `tr` +Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, SF Georgian, DejaVu Sans; on the web the first served font for `Geor`). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse the Russian rule. From c1dabd4b0a6879a32d9f6522f84095c97d406b4a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 24 Oct 2026 16:40:54 +0400 Subject: [PATCH 038/310] Text: Georgian families in the fallback candidates, so Georgian set in Inter draws with Noto Sans Georgian Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/fonts.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a0b64f4e3b..d80b9a208e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -78,6 +78,9 @@ pub const LAO_FAMILIES: &[&str] = &["Lao Sangam MN", "Lao MN", "Lao UI", "Noto S /// Myanmar/Burmese-capable families: macOS (Myanmar Sangam MN, Myanmar MN), Windows (Myanmar Text) /// and Linux (Noto). pub const MYANMAR_FAMILIES: &[&str] = &["Myanmar Sangam MN", "Myanmar MN", "Myanmar Text", "Noto Sans Myanmar"]; +/// Georgian-capable families: craft-fonts and Linux (Noto), Windows (Sylfaen). Georgian in a font +/// without Mkhedruli glyphs falls back to these. +pub const GEORGIAN_FAMILIES: &[&str] = &["Noto Sans Georgian", "Noto Serif Georgian", "Sylfaen"]; /// Indic-script families for document text. Windows has `Nirmala UI` for all nine scripts (from /// Windows 8); macOS has the Kohinoor and Sangam MN families; Linux has Noto Sans. Indic text in a /// font without these glyphs had no candidate to fall back to and drew .notdef boxes (#218). @@ -121,6 +124,7 @@ pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static s v.extend_from_slice(KHMER_FAMILIES); v.extend_from_slice(LAO_FAMILIES); v.extend_from_slice(MYANMAR_FAMILIES); + v.extend_from_slice(GEORGIAN_FAMILIES); for fams in INDIC_FAMILIES { for f in *fams { // Nirmala UI covers all nine scripts and is listed by each; probe it once. From 32d1563e6c4513797d6348100ec41283afd189da Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 25 Oct 2026 01:42:35 +0400 Subject: [PATCH 039/310] Text: macOS's .SF Georgian among the Georgian fallback families Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/fonts.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d80b9a208e..28e258b367 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -78,9 +78,9 @@ pub const LAO_FAMILIES: &[&str] = &["Lao Sangam MN", "Lao MN", "Lao UI", "Noto S /// Myanmar/Burmese-capable families: macOS (Myanmar Sangam MN, Myanmar MN), Windows (Myanmar Text) /// and Linux (Noto). pub const MYANMAR_FAMILIES: &[&str] = &["Myanmar Sangam MN", "Myanmar MN", "Myanmar Text", "Noto Sans Myanmar"]; -/// Georgian-capable families: craft-fonts and Linux (Noto), Windows (Sylfaen). Georgian in a font -/// without Mkhedruli glyphs falls back to these. -pub const GEORGIAN_FAMILIES: &[&str] = &["Noto Sans Georgian", "Noto Serif Georgian", "Sylfaen"]; +/// Georgian-capable families: craft-fonts and Linux (Noto), Windows (Sylfaen), macOS (".SF Georgian", +/// a hidden system face). Georgian in a font without Mkhedruli glyphs falls back to these. +pub const GEORGIAN_FAMILIES: &[&str] = &["Noto Sans Georgian", "Noto Serif Georgian", "Sylfaen", ".SF Georgian"]; /// Indic-script families for document text. Windows has `Nirmala UI` for all nine scripts (from /// Windows 8); macOS has the Kohinoor and Sangam MN families; Linux has Noto Sans. Indic text in a /// font without these glyphs had no candidate to fall back to and drew .notdef boxes (#218). From 239f5604a0a0c63a548444fbcef32cfc150cb73d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 01:31:26 +0200 Subject: [PATCH 040/310] 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 aef70e5afb..29588199fb 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 71fc57f495..df1fcc19fe 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 041/310] 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 29588199fb..2acdd18c63 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 df1fcc19fe..90662997c9 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 042/310] 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 b0cceb72a8..4a388c29e1 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 043/310] 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 6353f1f2cc..32eadaed41 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 044/310] 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 3a9a341316..36e63683d5 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 4db3dc2a34..07e9d52b04 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 023bbb4d79..7bcef086f2 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 b3b9d7b05e..effb776499 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 81426ccb5e..b5701313d4 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 0000000000..5bfbe48289 --- /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 045/310] 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 6353f1f2cc..24f39f2184 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 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 046/310] 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 36e63683d5..7d55d12c9e 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 5bfbe48289..c18166c842 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 dc343e363a927b497aa2b7851da74fff01b821d2 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 25 Oct 2026 19:06:47 +0400 Subject: [PATCH 047/310] i18n(ka): wording for six strings decided by the native-speaker review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking for updates…, Make Ramp Web Safe, Copy Color as HTML, Web Color Sliders, the release-page installer hint and the save-over warning. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 808b1bf5c6..cfc0650e06 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4445,12 +4445,12 @@ layerLabel Magenta მეწამული # Update checks (Help menu and startup) Automatically Check for Updates განახლებების ავტომატური შემოწმება Check for Updates… განახლებების შემოწმება… - Checking for updates… მიმდინარეობს განახლებების შემოწმება… + Checking for updates… განახლებების შემოწმება… Check Again ხელახლა შემოწმება Could not check for updates. განახლებების შემოწმება ვერ მოხერხდა. Installed version: {version} დაინსტალირებული ვერსია: {version} Open Download Page ჩამოტვირთვის გვერდის გახსნა - Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ ინსტალერი თქვენი სისტემისთვის. + Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ თქვენი სისტემის ინსტალატორი. PhotoCraft {version} is available. ხელმისაწვდომია PhotoCraft {version}. PhotoCraft is up to date. PhotoCraft განახლებულია. PhotoCraft Updates PhotoCraft-ის განახლებები @@ -4460,7 +4460,7 @@ layerLabel Magenta მეწამული HSB Sliders HSB სლაიდერები CMYK Sliders CMYK სლაიდერები Lab Sliders Lab სლაიდერები - Web Color Sliders ვებ-ფერების სლაიდერები + Web Color Sliders ვებფერის სლაიდერები Hue Cube ფერის ტონის კუბი Brightness Cube სიკაშკაშის კუბი Color Wheel ფერთა წრე @@ -4468,8 +4468,8 @@ layerLabel Magenta მეწამული CMYK Spectrum CMYK სპექტრი Grayscale Ramp ნაცრისფერი ტონების შკალა Current Colors მიმდინარე ფერები - Make Ramp Web Safe შკალაში მხოლოდ ვებ-ფერები - Copy Color as HTML ფერის კოპირება HTML-ის სახით + Make Ramp Web Safe ვებისთვის უსაფრთხო შკალა + Copy Color as HTML ფერის კოპირება HTML კოდად Copy Color's Hex Code ფერის Hex კოდის კოპირება # Styles panel: .asl style libraries Import Styles… სტილების იმპორტი… @@ -4482,6 +4482,6 @@ layerLabel Magenta მეწამული Damaged Photoshop document დაზიანებული Photoshop-ის დოკუმენტი The document “{name}” may be damaged. დოკუმენტი „{name}“ შესაძლოა დაზიანებული იყოს. Some layers, pixels or masks could not be read. Do you want to continue opening it? ზოგიერთი შრის, პიქსელის ან ნიღბის წაკითხვა ვერ მოხერხდა. გსურთ, გააგრძელოთ გახსნა? - If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ, ორიგინალზე გადაწერით შენახვამ შეიძლება წაუკითხავი მონაცემები სამუდამოდ დაკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ და ორიგინალს გადააწერთ, წაუკითხავი მონაცემები შეიძლება სამუდამოდ დაიკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. # Select and Mask: the workspace's edits (select.refineEdge) Edits ცვლილებები From b0d2c01b60e52a795b7b708a5669d8acb583339f Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 25 Oct 2026 22:09:37 +0400 Subject: [PATCH 048/310] i18n(ka): shorter wording for labels that were cut off Decided in the native-speaker review of the screens (all-t01). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 38 +++++++++++++++++----------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cfc0650e06..c281094daf 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -424,7 +424,7 @@ Fill Content შევსების შიგთავსი Deselect Layers შრეების მონიშვნის მოხსნა Find Layers შრეების ძებნა - Layer Comps შრეების კომპოზიციები + Layer Comps კომპოზიციები Layers შრეები All Layers ყველა შრე Hidden Layers დამალული შრეები @@ -721,7 +721,7 @@ Clone Source კლონირების წყარო Color ფერი Glyphs გლიფები - Gradients გრადიენტები + Gradients გრადიენტი Histogram ჰისტოგრამა History ისტორია Info ინფორმაცია @@ -736,7 +736,7 @@ Properties თვისებები Shapes ფიგურები Styles სტილები - Swatches ფერის ნიმუშები + Swatches ნიმუშები Timeline დროის ხაზი Tool Presets ხელსაწყოს პრესეტები Tools ხელსაწყოები @@ -1552,7 +1552,7 @@ Brush ფუნჯი Block ბლოკი Brush Pose ფუნჯის პოზა - Brush Tip Shape ფუნჯის წვერის ფორმა + Brush Tip Shape წვერის ფორმა Brush Tool ფუნჯის ხელსაწყო Build-up აეროგრაფი Burn Tool გამამუქებლის ხელსაწყო @@ -1729,7 +1729,7 @@ Selection მონიშნული SemiBold ნახევრად მუქი Shadows up to: ჩრდილები, ზედა ზღვარი: - Shape Dynamics ფორმის დინამიკა + Shape Dynamics ფორმის ცვლა Sharpen Tool გამკვეთრების ხელსაწყო Show Extras Options დამატებითი ელემენტების პარამეტრების ჩვენება Slice Select Tool ნაჭრის მონიშვნის ხელსაწყო @@ -1809,7 +1809,7 @@ Brush preview color ფუნჯის წინასწარი ნახვის ფერი Cache levels ქეშის დონეები Cache tile size ქეშის ფილის ზომა - Camera Raw Defaults Camera Raw-ის ნაგულისხმევი პარამეტრები + Camera Raw Defaults Camera Raw: ნაგულისხმევი Canvas border ტილოს საზღვარი Show canvas dots ტილოს წერტილების ჩვენება Canvas color ტილოს ფერი @@ -1839,7 +1839,7 @@ Enable floating document window docking მცურავი დოკუმენტის ფანჯრების მიმაგრების ჩართვა Enable narrow options bar ვიწრო პარამეტრების ზოლის ჩართვა Enabled ჩართულია - Enhanced Controls გაუმჯობესებული მართვის ელემენტები + Enhanced Controls გაუმჯობესებული მართვა Export clipboard გაცვლის ბუფერის ექსპორტი Extra large ძალიან დიდი File Handling ფაილებთან მუშაობა @@ -1860,7 +1860,7 @@ Gridline every ბადის ხაზი ყოველ Guide color დამხმარე ხაზის ფერი Guide style დამხმარე ხაზის სტილი - Guides, Grid & Slices დამხმარე ხაზები, ბადე და ნაჭრები + Guides, Grid & Slices ხაზები, ბადე, ნაჭრები Gutter შუალედი History Log ისტორიის ჟურნალი History states ისტორიის მდგომარეობები @@ -1946,11 +1946,11 @@ Text file ტექსტური ფაილი Tiny ძალიან პატარა Touch gestures სენსორული ჟესტები - Transparency & Gamut გამჭვირვალობა და ფერთა დიაპაზონი + Transparency & Gamut გამჭვირვალობა და გამი Type units ტექსტის ერთეულები UI font size ინტერფეისის შრიფტის ზომა UI scale ინტერფეისის მასშტაბი - Units & Rulers ერთეულები და სახაზავები + Units & Rulers ერთეულები, სახაზავები Use additional plugins folder პლაგინების დამატებითი საქაღალდის გამოყენება Use esc to commit დადასტურება Esc-ით Use gpu GPU-ის გამოყენება @@ -3029,7 +3029,7 @@ layerLabel No Color ფერის გარეშე Clipping Mask მომჭრელი ნიღაბი Tool Options ხელსაწყოების პარამეტრები Color Picker ფერის ამრჩევი - Color Swatches ფერის ნიმუშები + Color Swatches ნიმუშები Linear Dodge წრფივი გაღიავება Preview Image გამოსახულების გადახედვა Save As შენახვა როგორც @@ -3420,7 +3420,7 @@ layerLabel No Color ფერის გარეშე Circle წრე Clumped შეჯგუფებული Coarse Dots მსხვილი წერტილები - Colored Pencil ფერადი ფანქარი + Colored Pencil ფანქარი Contrasty კონტრასტული Cool ცივი Craquelure კრაკელიური @@ -3447,7 +3447,7 @@ layerLabel No Color ფერის გარეშე Enlarged გადიდებული Even Fields ლუწი ველები Faded Film გახუნებული ფირი - Film Grain ფირის მარცვლოვნება + Film Grain ფირის მარცვალი Fine წვრილი Fine Dots წვრილი წერტილები Flat ბრტყელი @@ -3488,7 +3488,7 @@ layerLabel No Color ფერის გარეშე Out From Center ცენტრიდან გარეთ Oval ოვალი Overlay Edge კიდის გადაფარვა - Palette Knife პალიტრის დანა + Palette Knife მასტიხინი Parallel პარალელური Patchwork ნაჭრების მოზაიკა Pentagon ხუთკუთხედი @@ -3580,7 +3580,7 @@ layerLabel No Color ფერის გარეშე Tiny Lens პატარა ლინზა To Center ცენტრისკენ Unaltered უცვლელი - Underpainting ქვედა ფენის ხატვა + Underpainting ქვედა ფენა Underwater წყალქვეშა Uniform ერთგვაროვანი Upper ზედა @@ -3692,7 +3692,7 @@ layerLabel No Color ფერის გარეშე Oil Paint ზეთის საღებავი Open As გახსნა როგორც Package შეფუთვა - Paint Daubs საღებავის ლაქები + Paint Daubs მონასმები Path Blur ტრაექტორიის გასწვრივ გაბუნდოვნება Paths to Illustrator ტრაექტორიები Illustrator-ში Photomerge Photomerge @@ -3701,11 +3701,11 @@ layerLabel No Color ფერის გარეშე Place Embedded ჩაშენებულის განთავსება Place Linked დაკავშირებულის განთავსება Place Scale Marker მასშტაბის მარკერის განთავსება - Plastic Wrap პლასტიკური შეფუთვა + Plastic Wrap ცელოფანი Pointillize პუანტილიზმი Polar Coordinates პოლარული კოორდინატები Polar To Rectangular პოლარულიდან მართკუთხაში - Poster Edges პოსტერის კიდეები + Poster Edges პოსტერის კიდე Preset Manager პრესეტების მართვა Profile პროფილი Radial Blur რადიალური გაბუნდოვნება @@ -3734,7 +3734,7 @@ layerLabel No Color ფერის გარეშე Shear დახრა Smart Blur ჭკვიანი გაბუნდოვნება Smart Sharpen ჭკვიანი გამკვეთრება - Smudge Stick გამღაბნელი ჩხირი + Smudge Stick ღაბნის ჯოხი Solid Color მყარი ფერი Spherize სფერიზება Spin Blur ბრუნვითი გაბუნდოვნება From 242d5d23a11ff30e10702764b865655a9df89b4b Mon Sep 17 00:00:00 2001 From: GyoJoong Kum Date: Mon, 26 Oct 2026 02:02:20 +0000 Subject: [PATCH 049/310] 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 23aca76e64..bb44c23089 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 1ce88605dc..433765c01f 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 58d11d91ea46041650058284163008d534431329 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 16:05:36 -0700 Subject: [PATCH 050/310] Equalize: with a selection, choose the selected area only or the entire image based on it Image > Adjustments > Equalize with an active selection now opens a dialog with Photoshop's two choices. Equalize Selected Area Only, the default, changes only the selected pixels, as Equalize did before. Equalize Entire Image Based On Selected Area maps every pixel of the layer within the canvas by the selected pixels' histogram. image.adjustments.equalize takes selectedAreaOnly (default true), and a script that passes it runs without the dialog. .atn save writes the Eqlz step without this setting, so it refuses a step with selectedAreaOnly false. --- crates/engine/src/atn_cmds.rs | 14 ++-- crates/engine/src/atn_cmds/tests.rs | 8 ++- crates/engine/src/extra_cmds.rs | 89 ++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/equalize_ui.rs | 45 ++++++++++++ crates/ui-egui/src/equalize_ui/tests.rs | 92 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 4 ++ docs/target-app-parity.md | 3 +- 29 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/equalize_ui.rs create mode 100644 crates/ui-egui/src/equalize_ui/tests.rs diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 244c21479a..86067c5666 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1014,7 +1014,7 @@ fn applies_at_canvas_bounds(p: &Value) -> bool { } /// Settings a mapped event has no field for, by command: `(param, the value the event plays as)`. -/// Photoshop's event has nowhere to put these, so a step that sets one cannot be written: it +/// The exported event has nowhere to put these, so a step that sets one cannot be written: it /// would import as a different edit, silently. Such a step is refused, the way [`pixel_target`] /// refuses a filter aimed at a mask -- and no key Photoshop does not define is invented for it, /// which the clean-room rule forbids and which would make the file wrong in another reader. @@ -1023,8 +1023,8 @@ fn applies_at_canvas_bounds(p: &Value) -> bool { /// How a setting the event cannot carry behaves once the step is replayed without it. Which of /// the two a setting takes follows the command's own reading of it, not the parameter spec. enum Lost { - /// Replay uses this value, so only a step already at it survives. Numbers and strings are - /// both compared as written, since the commands below read these settings as written. + /// Replay uses this value, so only a step already at it survives. Numbers, strings and + /// booleans are compared as written, since the commands below read these settings as written. ReplaysAs(&'static str), /// The command reads only this one spelling as anything other than what replay does, so every /// other value survives -- a differently cased one included, because it too plays as replay's. @@ -1040,6 +1040,8 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ // the exact string "roundness" -- see `preserve` in filters.rs. ("filter.other.minimum", &[("preserve", Lost::DiffersAt("roundness"))]), ("filter.other.maximum", &[("preserve", Lost::DiffersAt("roundness"))]), + // Eqlz is exported with no fields; replay equalizes the selected area only. + ("image.adjustments.equalize", &[("selectedAreaOnly", Lost::ReplaysAs("true"))]), ]; /// Whether every setting of `p` that its event cannot hold would play the same without it @@ -1048,7 +1050,11 @@ fn holds_its_settings(id: &str, p: &Value) -> bool { let Some((_, settings)) = UNHOLDABLE.iter().find(|(cmd, _)| *cmd == id) else { return true }; settings.iter().all(|(param, lost)| match (p.get(param), lost) { (None | Some(Value::Null), _) => true, - (Some(v), Lost::ReplaysAs(played)) => v.as_str() == Some(played) || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)), + (Some(v), Lost::ReplaysAs(played)) => { + v.as_str() == Some(played) + || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)) + || v.as_bool().is_some_and(|b| played.parse::() == Ok(b)) + } (Some(v), Lost::DiffersAt(spelling)) => v.as_str() != Some(spelling), }) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index afd3895393..2e45b68f45 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -426,9 +426,10 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() { assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}"); } -/// A setting Photoshop's event has no field for is dropped by export, and the step then plays back +/// A setting the exported event has no field for is dropped by export, and the step then plays back /// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and -/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. +/// Maximum's `preserve`, Equalize's `selectedAreaOnly`. Such a step is refused, as one with no +/// equivalent event already is. #[test] fn a_setting_the_event_cannot_hold_is_refused() { for (id, params) in [ @@ -436,6 +437,7 @@ fn a_setting_the_event_cannot_hold_is_refused() { ("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})), ("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": false})), ] { let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(id.into(), params)])); @@ -465,6 +467,8 @@ fn settings_at_the_value_the_event_plays_still_export() { // review). ("filter.other.minimum", json!({"radius": 4, "preserve": "Squareness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "Roundness"})), + ("image.adjustments.equalize", json!({"selectedAreaOnly": true})), + ("image.adjustments.equalize", json!({})), ] { let mut s = Session::new(); s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())])); diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index d5364cfd88..423bf0f4cf 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -76,12 +76,18 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String { /// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that /// grayscale surface is rewritten instead, as the core filters do (#935). fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { + map_pixels_in(s, label, p, false, f) +} + +/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and +/// every pixel takes its result in full. +fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { let id = layer_param(s, &Value::Null)?; let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels; s.edit(label, |doc, _| { let canvas = doc.bounds(); let selection = doc.selection.clone(); - let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); + let area = if whole { canvas } else { selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)) }; let (surf, lock_alpha) = if channel { let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(id), p)? else { return Ok(()) }; (surf, false) @@ -109,7 +115,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut f(area, &mut px, &cover); let mut enc = [0.0f32; 8]; for (i, out) in raw.chunks_exact_mut(n).enumerate() { - let k = cover[i]; + let k = if whole { 1.0 } else { cover[i] }; if k <= 0.0 { continue; } @@ -230,9 +236,16 @@ fn stroke(s: &mut Session, p: &Value) -> Result { } /// Histogram equalization, like Photoshop's: one CDF over the R, G and B values of -/// the selected pixels, applied to every channel. +/// the selected pixels, applied to every channel. With a selection, `selectedAreaOnly: false` +/// applies the selection's mapping to the layer within the canvas (Photoshop's Equalize Entire +/// Image Based On Selected Area). fn equalize(s: &mut Session, p: &Value) -> Result { - map_pixels(s, "Equalize", p, |_, px, cover| { + let whole = match p.get("selectedAreaOnly") { + None | Some(Value::Null) => false, + Some(Value::Bool(only)) => !only, + Some(_) => return Err(EngineError::BadParams { cmd: "image.adjustments.equalize".into(), msg: "selectedAreaOnly must be true or false".into() }), + }; + map_pixels_in(s, "Equalize", p, whole, |_, px, cover| { let mut hist = [0f64; 256]; for (p, &k) in px.iter().zip(cover) { if k > 0.0 && p[3] > 0.0 { @@ -861,7 +874,15 @@ pub fn specs() -> Vec { })?; Ok(json!({"selected": true})) }), - spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, equalize), + spec!( + "image.adjustments.equalize", + "Equalize", + &["Image", "Adjustments"], + None, + r#"{"selectedAreaOnly":bool=true (with a selection, false equalizes the layer within the canvas by the selection's histogram)}"#, + has_pixels, + equalize + ), spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| { let d = s.active().ok_or(EngineError::NoDocument)?; let all = reveal_all_bounds(&d.doc); @@ -1536,6 +1557,64 @@ mod tests { } } + /// Equalize with a selection: by default only the selected pixels change, as Photoshop's + /// Equalize Selected Area Only; `selectedAreaOnly: false` maps the layer within the canvas by the + /// selected pixels' histogram, as its Equalize Entire Image Based On Selected Area. + #[test] + fn equalize_entire_image_based_on_the_selected_area() { + let setup = |depth: u32| { + let mut s = session(depth); + paint_square(&mut s, Rect::new(0, 0, 30, 15), [0.4, 0.4, 0.4, 1.0]); + paint_square(&mut s, Rect::new(0, 15, 30, 30), [0.5, 0.5, 0.5, 1.0]); + // Darker than anything selected: the selection's mapping sends it to black. + paint_square(&mut s, Rect::new(30, 0, 40, 30), [0.3, 0.3, 0.3, 1.0]); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + s + }; + for depth in [8, 16, 32] { + for params in [json!({}), json!({"selectedAreaOnly": true}), json!({"selectedAreaOnly": null})] { + let mut s = setup(depth); + s.execute("image.adjustments.equalize", params.clone()).unwrap(); + let inside = (pixel(&s, 1, 1)[0], pixel(&s, 1, 20)[0]); + assert!(inside.0 < 0.05 && inside.1 > 0.95, "{depth} {params}: inside {inside:?}"); + let outside = [pixel(&s, 25, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]]; + assert!(outside.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth} {params}: outside {outside:?}"); + } + let mut s = setup(depth); + s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": false})).unwrap(); + for (x, y, want_light) in [(1, 1, false), (1, 20, true), (25, 1, false), (25, 20, true), (35, 1, false), (35, 20, false)] { + let v = pixel(&s, x, y)[0]; + assert!(if want_light { v > 0.95 } else { v < 0.05 }, "{depth}: ({x}, {y}) = {v}"); + } + assert!(doc(&s).selection.is_some(), "{depth}: the selection stays"); + s.undo(); + let undone = [pixel(&s, 1, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]]; + assert!(undone.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth}: one undo step {undone:?}"); + } + } + + /// Without a selection, `selectedAreaOnly: false` gives the same pixels as plain Equalize. + #[test] + fn equalize_entire_image_without_a_selection_is_plain_equalize() { + let run = |params: Value| { + let mut s = session(8); + paint_square(&mut s, Rect::new(0, 0, 40, 15), [0.4, 0.4, 0.4, 1.0]); + paint_square(&mut s, Rect::new(0, 15, 40, 30), [0.5, 0.5, 0.5, 1.0]); + s.execute("image.adjustments.equalize", params).unwrap(); + active(&s).surface().unwrap().read_region(Rect::new(0, 0, 40, 30)) + }; + assert_eq!(run(json!({"selectedAreaOnly": false})), run(json!({}))); + } + + #[test] + fn equalize_refuses_a_selected_area_only_that_is_not_a_boolean() { + for bad in [json!("false"), json!(0), json!([true]), json!({})] { + let mut s = session(8); + let e = s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": bad})).unwrap_err().to_string(); + assert!(e.contains("selectedAreaOnly"), "{bad}: {e}"); + } + } + /// A selection masks Clouds but doesn't change their size: inside it they are the canvas's /// clouds, as when Clouds sized itself by the document (before it became a filter). #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6dfa4f70d1..154e1658c1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -502,6 +502,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(ui, &mut fields), + DialogKind::Command if crate::equalize_ui::owns(&fields) => crate::equalize_ui::body(ui, &mut fields), DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields), DialogKind::Command => {} DialogKind::LayerStyle => crate::layer_style::body(app, ui, &mut fields), diff --git a/crates/ui-egui/src/equalize_ui.rs b/crates/ui-egui/src/equalize_ui.rs new file mode 100644 index 0000000000..42aafeb12a --- /dev/null +++ b/crates/ui-egui/src/equalize_ui.rs @@ -0,0 +1,45 @@ +//! Image › Adjustments › Equalize with a selection: the dialog that asks what to equalize, as in +//! Photoshop. Equalize Selected Area Only (the default) evenly distributes the selected pixels; +//! Equalize Entire Image Based On Selected Area maps the layer within the canvas by the selected +//! pixels' histogram. OK runs `image.adjustments.equalize` with `selectedAreaOnly`. Without a selection, +//! Equalize runs at once. +//! +//! The dialog is an ordinary one in the UI state, so `ui.inspect`, `ui.dialog.confirm` and +//! `ui.dialog.cancel` drive it. + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; + +const MARK: &str = "__equalize"; +pub const COMMAND: &str = "image.adjustments.equalize"; +const KEY: &str = "selectedAreaOnly"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Whether Equalize asks first: the active document has a selection and Equalize can run. +pub fn asks(app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| st.doc.selection.is_some()) && app.session.is_enabled(COMMAND) +} + +pub fn open(app: &mut PhotocraftApp) -> u64 { + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("Equalize")); + f.insert(KEY.into(), json!(true)); + app.ui.open_dialog(DialogKind::Command, f) +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + let mut only = f.get(KEY).and_then(Value::as_bool).unwrap_or(true); + ui.radio_value(&mut only, true, tl!("Equalize Selected Area Only")); + ui.radio_value(&mut only, false, tl!("Equalize Entire Image Based On Selected Area")); + f.insert(KEY.into(), json!(only)); +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/equalize_ui/tests.rs b/crates/ui-egui/src/equalize_ui/tests.rs new file mode 100644 index 0000000000..c0f7ef6006 --- /dev/null +++ b/crates/ui-egui/src/equalize_ui/tests.rs @@ -0,0 +1,92 @@ +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; +use crate::menus::invoke as menu; + +/// A 40 × 30 layer: the top half at 0.4 gray, the bottom half at 0.5, with the left half selected +/// when `select`. +fn setup(select: bool) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 40, "height": 30})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 15})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 15, "width": 40, "height": 15})).unwrap(); + app.run("edit.fill", json!({"color": [0.5, 0.5, 0.5]})).unwrap(); + if select { + app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + } else { + app.run("select.deselect", json!({})).unwrap(); + } + (app, egui::Context::default()) +} + +fn gray(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[0] +} + +#[test] +fn equalize_with_a_selection_asks_and_defaults_to_the_selected_area_only() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(d).unwrap().fields; + assert!(owns(f)); + assert_eq!(f[KEY], json!(true)); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "nothing changes before OK"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 1, 1) < 0.05 && gray(&app, 1, 20) > 0.95, "the selected area is equalized"); + assert!((gray(&app, 30, 1) - 0.4).abs() < 0.01 && (gray(&app, 30, 20) - 0.5).abs() < 0.01, "the rest is unchanged"); +} + +#[test] +fn entire_image_based_on_the_selected_area_maps_the_whole_layer() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(d).unwrap().fields.insert(KEY.into(), json!(false)); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 30, 1) < 0.05 && gray(&app, 30, 20) > 0.95, "outside the selection too"); +} + +#[test] +fn cancel_leaves_the_layer_unchanged() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + crate::dialogs::cancel(&mut app, d).unwrap(); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01 && (gray(&app, 1, 20) - 0.5).abs() < 0.01); +} + +#[test] +fn without_a_selection_or_with_params_equalize_runs_at_once() { + let (mut app, ctx) = setup(false); + let r = menu(&mut app, &ctx, COMMAND, json!({})).unwrap(); + assert!(r.get("dialog").is_none() && app.ui.dialogs.is_empty(), "{r}"); + assert!(gray(&app, 30, 1) < 0.05 && gray(&app, 30, 20) > 0.95); + let (mut scripted, ctx) = setup(true); + menu(&mut scripted, &ctx, COMMAND, json!({"selectedAreaOnly": false})).unwrap(); + assert!(scripted.ui.dialogs.is_empty()); + assert!(gray(&scripted, 30, 1) < 0.05, "a script's `selectedAreaOnly` runs without the dialog"); +} + +#[test] +fn the_dialog_shows_both_choices() { + let (mut app, _) = setup(true); + open(&mut app); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::dialogs::show(app, &ctx); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + assert!(h.query_by_label("Equalize Selected Area Only").is_some()); + h.get_by_label("Equalize Entire Image Based On Selected Area").click(); + h.run_steps(2); + assert_eq!(h.state().ui.dialogs[0].fields[KEY], json!(false)); + assert!(h.query_by_label("OK").is_some() && h.query_by_label("Cancel").is_some()); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 925d19568b..3862c6884a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -280,6 +280,8 @@ Match Color… Přizpůsobit barvy… Replace Color… Nahradit barvu… Equalize Vyrovnat + Equalize Selected Area Only Vyrovnat pouze výběr + Equalize Entire Image Based On Selected Area Vyrovnat celý obraz podle výběru Auto Tone Automatický tón Auto Contrast Automatický kontrast Auto Color Automatické barvy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6558daa51d..3e01a363fd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -279,6 +279,8 @@ Match Color… Farbabgleich… Replace Color… Farbe ersetzen… Equalize Angleichen + Equalize Selected Area Only Nur ausgewählten Bereich angleichen + Equalize Entire Image Based On Selected Area Gesamtes Bild anhand des ausgewählten Bereichs angleichen Auto Tone Automatische Tonwertkorrektur Auto Contrast Automatischer Kontrast Auto Color Automatische Farbe diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5f1a3d3538..b4b57b62ed 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -670,6 +670,8 @@ Channel Mixer Μείκτης καναλιών Desaturate Αφαίρεση κορεσμού Equalize Εξισορρόπηση + Equalize Selected Area Only Εξισορρόπηση μόνο της επιλεγμένης περιοχής + Equalize Entire Image Based On Selected Area Εξισορρόπηση ολόκληρης της εικόνας βάσει της επιλεγμένης περιοχής Auto Tone Αυτόματη διόρθωση τόνων Auto Contrast Αυτόματη αντίθεση Auto Color Αυτόματη διόρθωση χρωμάτων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 879572c9f4..de074dc1f9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -276,6 +276,8 @@ Match Color… Igualar color… Replace Color… Reemplazar color… Equalize Ecualizar + Equalize Selected Area Only Ecualizar solo la selección + Equalize Entire Image Based On Selected Area Ecualizar toda la imagen según la selección Auto Tone Tono automático Auto Contrast Contraste automático Auto Color Color automático diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9692437211..8e0e5b89ca 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -272,6 +272,8 @@ Match Color… Faire correspondre la couleur… Replace Color… Remplacer la couleur… Equalize Égaliser + Equalize Selected Area Only Égaliser uniquement la sélection + Equalize Entire Image Based On Selected Area Égaliser toute l'image d'après la sélection Auto Tone Tonalité automatique Auto Contrast Contraste automatique Auto Color Couleur automatique diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1931aaa2b0..4835185682 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -128,6 +128,8 @@ Edit Szerkesztés Enhanced Controls… Bővített vezérlők… Equalize Kiegyenlítés + Equalize Entire Image Based On Selected Area A teljes kép kiegyenlítése a kijelölt terület alapján + Equalize Selected Area Only Csak a kijelölt terület kiegyenlítése Exit Kilépés Export As… Exportálás másként… Export Preferences… Beállítások exportálása… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index eb890e9bce..56da52fdb7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -265,6 +265,8 @@ Match Color… Cocokkan Warna… Replace Color… Ganti Warna… Equalize Ekualisasi + Equalize Selected Area Only Ekualisasi seleksi saja + Equalize Entire Image Based On Selected Area Ekualisasi seluruh gambar berdasarkan seleksi Auto Tone Auto Tone Auto Contrast Auto Kontras Auto Color Auto Warna diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 661192d7b6..0519f05e6f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -273,6 +273,8 @@ Match Color… Corrispondenza colore… Replace Color… Sostituisci colore… Equalize Equalizza + Equalize Selected Area Only Equalizza solo l'area selezionata + Equalize Entire Image Based On Selected Area Equalizza l'intera immagine in base all'area selezionata Auto Tone Tono automatico Auto Contrast Contrasto automatico Auto Color Colore automatico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c5c0b02fc0..8c2f55080f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -275,6 +275,8 @@ Match Color… カラーを一致… Replace Color… 色を置き換え… Equalize 平均化 + Equalize Selected Area Only 選択範囲のみ平均化 + Equalize Entire Image Based On Selected Area 選択範囲に基づいて画像全体を平均化 Auto Tone 自動トーン補正 Auto Contrast 自動コントラスト Auto Color 自動カラー補正 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2c730c1877..9324cfdb2b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -262,6 +262,8 @@ Match Color… 색상 일치… Replace Color… 색상 대체… Equalize 균일화 + Equalize Selected Area Only 선택 영역만 균일화 + Equalize Entire Image Based On Selected Area 선택 영역을 기준으로 전체 이미지 균일화 Auto Tone 자동 톤 Auto Contrast 자동 대비 Auto Color 자동 색상 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a8539cd159..40e2a1301a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -266,6 +266,8 @@ Match Color… Kleur afstemmen… Replace Color… Kleur vervangen… Equalize Egaliseren + Equalize Selected Area Only Alleen geselecteerd gebied egaliseren + Equalize Entire Image Based On Selected Area Hele afbeelding egaliseren op basis van geselecteerd gebied Auto Tone Automatische tooncorrectie Auto Contrast Automatisch contrast Auto Color Automatische kleur diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6b2ad286a0..053b0b8778 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -273,6 +273,8 @@ Match Color… Dopasuj kolor… Replace Color… Zamień kolor… Equalize Wyrównaj + Equalize Selected Area Only Wyrównaj tylko zaznaczony obszar + Equalize Entire Image Based On Selected Area Wyrównaj cały obraz na podstawie zaznaczonego obszaru Auto Tone Automatyczne tony Auto Contrast Automatyczny kontrast Auto Color Automatyczny kolor diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 098ca1f8a6..7519f45a25 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -267,6 +267,8 @@ Match Color… Corresponder cores… Replace Color… Substituir cor… Equalize Equalizar + Equalize Selected Area Only Equalizar somente a área selecionada + Equalize Entire Image Based On Selected Area Equalizar a imagem inteira com base na área selecionada Auto Tone Tom automático Auto Contrast Contraste automático Auto Color Cor automática diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d3e6033306..3ae98ae541 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -832,6 +832,8 @@ Match Color… Подбор цвета… Replace Color… Замена цвета… Equalize Выравнивание + Equalize Selected Area Only Выровнять только выделенную область + Equalize Entire Image Based On Selected Area Выровнять всё изображение по выделенной области HDR Toning… HDR-тонирование… Shadows/Highlights… Тени/Света… Amount Эффект diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 6a6dfb425f..f56b9bbfd7 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -258,6 +258,8 @@ Match Color… Uskladi boje… Replace Color… Zameni boju… Equalize Izjednači + Equalize Selected Area Only Izjednači samo selekciju + Equalize Entire Image Based On Selected Area Izjednači celu sliku prema selekciji Auto Tone Automatski ton Auto Contrast Automatski kontrast Auto Color Automatske boje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d725b8c9ac..aad5052ab9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -253,6 +253,8 @@ Match Color… Rengi Eşleştir… Replace Color… Rengi Değiştir… Equalize Eşitle + Equalize Selected Area Only Yalnızca Seçili Alanı Eşitle + Equalize Entire Image Based On Selected Area Tüm Görüntüyü Seçili Alana Göre Eşitle Auto Tone Otomatik Ton Auto Contrast Otomatik Kontrast Auto Color Otomatik Renk diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 777f03f60a..8679a2585d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -809,6 +809,8 @@ Match Color… Узгодити колір… Replace Color… Замінити колір… Equalize Вирівняти гістограму + Equalize Selected Area Only Вирівняти лише виділену область + Equalize Entire Image Based On Selected Area Вирівняти все зображення за виділеною областю HDR Toning… Тонування HDR… Shadows/Highlights… Тіні/світлі ділянки… Amount Величина diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 397b02d38d..a2c93255f7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -275,6 +275,8 @@ Enable Vector Mask Bật mặt nạ vector Entropy Entropy Equalize Cân bằng histogram + Equalize Entire Image Based On Selected Area Cân bằng histogram toàn bộ hình ảnh theo vùng chọn + Equalize Selected Area Only Chỉ cân bằng histogram vùng chọn Exclude Overlapping Shapes Loại phần chồng hình Export Contents… Xuất nội dung… Exposure… Phơi sáng… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e6b9080ae6..8dfb1c1284 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -279,6 +279,8 @@ Match Color… 匹配颜色… Replace Color… 替换颜色… Equalize 色调均化 + Equalize Selected Area Only 仅色调均化选区 + Equalize Entire Image Based On Selected Area 基于选区色调均化整个图像 Auto Tone 自动色调 Auto Contrast 自动对比度 Auto Color 自动颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1586d47bf8..9190d01996 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -284,6 +284,8 @@ Match Color… 符合顏色… Replace Color… 取代顏色… Equalize 均勻分佈 + Equalize Selected Area Only 僅均勻分佈選取範圍 + Equalize Entire Image Based On Selected Area 依據選取範圍均勻分佈整個影像 Auto Tone 自動色調 Auto Contrast 自動對比 Auto Color 自動色彩 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index adf7b4f959..9b6e5c6509 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -93,6 +93,7 @@ pub mod duplicate_layer_ui; pub mod edit_history; pub mod egui_fonts; pub mod enable_rules; +pub mod equalize_ui; pub mod eraser_ui; pub mod export_dialog; pub mod eyedropper_ui; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 07df45a66a..ab6969b85e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -602,6 +602,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::sizing::open(app, sz).ok_or("no document")?})) } "view.newGuide" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::new_guide_ui::open(app)})), + // Image › Adjustments › Equalize with a selection asks what to equalize, as in Photoshop. + crate::equalize_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) && crate::equalize_ui::asks(app) => { + Ok(json!({"dialog": crate::equalize_ui::open(app)})) + } fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|o| o.is_empty()) => { Ok(json!({"dialog": crate::filter_dialog::open(app, fl)})) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5c9fcd4b99..d3d6632281 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-25 · **Change:** minor (12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; 12 of 178 preferences do nothing, from the scorecard; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; 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; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **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); @@ -574,6 +574,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Adjustments: Image › Adjustments › Equalize with a selection asks, as Photoshop does, whether to equalize the selected area only or the entire image based on the selected area (`image.adjustments.equalize` `selectedAreaOnly`); percentages unchanged | | 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged | | 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged | | 2026-10-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged | From 9fe5d908bffe2f725f799491c9c56676fdccfc6b Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 27 Oct 2026 09:43:08 +0700 Subject: [PATCH 051/310] 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 c5581d3404..f1015af30b 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 82ac54070cf33f32519fc91eef0265938a3ef290 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Tue, 27 Oct 2026 15:43:05 +0400 Subject: [PATCH 052/310] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 130 ++++++++++++++++++++------------- 1 file changed, 81 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c281094daf..74785e9ffd 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -424,7 +424,7 @@ Fill Content შევსების შიგთავსი Deselect Layers შრეების მონიშვნის მოხსნა Find Layers შრეების ძებნა - Layer Comps კომპოზიციები + Layer Comps შრეების კომპოზიციები Layers შრეები All Layers ყველა შრე Hidden Layers დამალული შრეები @@ -721,7 +721,7 @@ Clone Source კლონირების წყარო Color ფერი Glyphs გლიფები - Gradients გრადიენტი + Gradients გრადიენტები Histogram ჰისტოგრამა History ისტორია Info ინფორმაცია @@ -736,7 +736,7 @@ Properties თვისებები Shapes ფიგურები Styles სტილები - Swatches ნიმუშები + Swatches ფერის ნიმუშები Timeline დროის ხაზი Tool Presets ხელსაწყოს პრესეტები Tools ხელსაწყოები @@ -761,6 +761,7 @@ Split Warp Vertically დეფორმაციის ვერტიკალურად გაყოფა Statistics… სტატისტიკა… Refine Strokes დაზუსტების შტრიხები + Edits ცვლილებები @plural {n} item|{n} items {n} ელემენტი Auto ავტომატური (print to the printer) (პრინტერზე ბეჭდვა) @@ -1552,7 +1553,7 @@ Brush ფუნჯი Block ბლოკი Brush Pose ფუნჯის პოზა - Brush Tip Shape წვერის ფორმა + Brush Tip Shape ფუნჯის წვერის ფორმა Brush Tool ფუნჯის ხელსაწყო Build-up აეროგრაფი Burn Tool გამამუქებლის ხელსაწყო @@ -1729,7 +1730,7 @@ Selection მონიშნული SemiBold ნახევრად მუქი Shadows up to: ჩრდილები, ზედა ზღვარი: - Shape Dynamics ფორმის ცვლა + Shape Dynamics ფორმის დინამიკა Sharpen Tool გამკვეთრების ხელსაწყო Show Extras Options დამატებითი ელემენტების პარამეტრების ჩვენება Slice Select Tool ნაჭრის მონიშვნის ხელსაწყო @@ -1809,7 +1810,7 @@ Brush preview color ფუნჯის წინასწარი ნახვის ფერი Cache levels ქეშის დონეები Cache tile size ქეშის ფილის ზომა - Camera Raw Defaults Camera Raw: ნაგულისხმევი + Camera Raw Defaults Camera Raw-ის ნაგულისხმევი პარამეტრები Canvas border ტილოს საზღვარი Show canvas dots ტილოს წერტილების ჩვენება Canvas color ტილოს ფერი @@ -1839,7 +1840,7 @@ Enable floating document window docking მცურავი დოკუმენტის ფანჯრების მიმაგრების ჩართვა Enable narrow options bar ვიწრო პარამეტრების ზოლის ჩართვა Enabled ჩართულია - Enhanced Controls გაუმჯობესებული მართვა + Enhanced Controls გაუმჯობესებული მართვის ელემენტები Export clipboard გაცვლის ბუფერის ექსპორტი Extra large ძალიან დიდი File Handling ფაილებთან მუშაობა @@ -1860,7 +1861,7 @@ Gridline every ბადის ხაზი ყოველ Guide color დამხმარე ხაზის ფერი Guide style დამხმარე ხაზის სტილი - Guides, Grid & Slices ხაზები, ბადე, ნაჭრები + Guides, Grid & Slices დამხმარე ხაზები, ბადე და ნაჭრები Gutter შუალედი History Log ისტორიის ჟურნალი History states ისტორიის მდგომარეობები @@ -1946,11 +1947,11 @@ Text file ტექსტური ფაილი Tiny ძალიან პატარა Touch gestures სენსორული ჟესტები - Transparency & Gamut გამჭვირვალობა და გამი + Transparency & Gamut გამჭვირვალობა და ფერთა დიაპაზონი Type units ტექსტის ერთეულები UI font size ინტერფეისის შრიფტის ზომა UI scale ინტერფეისის მასშტაბი - Units & Rulers ერთეულები, სახაზავები + Units & Rulers ერთეულები და სახაზავები Use additional plugins folder პლაგინების დამატებითი საქაღალდის გამოყენება Use esc to commit დადასტურება Esc-ით Use gpu GPU-ის გამოყენება @@ -3029,7 +3030,7 @@ layerLabel No Color ფერის გარეშე Clipping Mask მომჭრელი ნიღაბი Tool Options ხელსაწყოების პარამეტრები Color Picker ფერის ამრჩევი - Color Swatches ნიმუშები + Color Swatches ფერის ნიმუშები Linear Dodge წრფივი გაღიავება Preview Image გამოსახულების გადახედვა Save As შენახვა როგორც @@ -3420,7 +3421,7 @@ layerLabel No Color ფერის გარეშე Circle წრე Clumped შეჯგუფებული Coarse Dots მსხვილი წერტილები - Colored Pencil ფანქარი + Colored Pencil ფერადი ფანქარი Contrasty კონტრასტული Cool ცივი Craquelure კრაკელიური @@ -3447,7 +3448,7 @@ layerLabel No Color ფერის გარეშე Enlarged გადიდებული Even Fields ლუწი ველები Faded Film გახუნებული ფირი - Film Grain ფირის მარცვალი + Film Grain ფირის მარცვლოვნება Fine წვრილი Fine Dots წვრილი წერტილები Flat ბრტყელი @@ -3488,7 +3489,7 @@ layerLabel No Color ფერის გარეშე Out From Center ცენტრიდან გარეთ Oval ოვალი Overlay Edge კიდის გადაფარვა - Palette Knife მასტიხინი + Palette Knife პალიტრის დანა Parallel პარალელური Patchwork ნაჭრების მოზაიკა Pentagon ხუთკუთხედი @@ -3580,7 +3581,7 @@ layerLabel No Color ფერის გარეშე Tiny Lens პატარა ლინზა To Center ცენტრისკენ Unaltered უცვლელი - Underpainting ქვედა ფენა + Underpainting ქვედა ფენის ხატვა Underwater წყალქვეშა Uniform ერთგვაროვანი Upper ზედა @@ -3692,7 +3693,7 @@ layerLabel No Color ფერის გარეშე Oil Paint ზეთის საღებავი Open As გახსნა როგორც Package შეფუთვა - Paint Daubs მონასმები + Paint Daubs საღებავის ლაქები Path Blur ტრაექტორიის გასწვრივ გაბუნდოვნება Paths to Illustrator ტრაექტორიები Illustrator-ში Photomerge Photomerge @@ -3701,11 +3702,11 @@ layerLabel No Color ფერის გარეშე Place Embedded ჩაშენებულის განთავსება Place Linked დაკავშირებულის განთავსება Place Scale Marker მასშტაბის მარკერის განთავსება - Plastic Wrap ცელოფანი + Plastic Wrap პლასტიკური შეფუთვა Pointillize პუანტილიზმი Polar Coordinates პოლარული კოორდინატები Polar To Rectangular პოლარულიდან მართკუთხაში - Poster Edges პოსტერის კიდე + Poster Edges პოსტერის კიდეები Preset Manager პრესეტების მართვა Profile პროფილი Radial Blur რადიალური გაბუნდოვნება @@ -3734,7 +3735,7 @@ layerLabel No Color ფერის გარეშე Shear დახრა Smart Blur ჭკვიანი გაბუნდოვნება Smart Sharpen ჭკვიანი გამკვეთრება - Smudge Stick ღაბნის ჯოხი + Smudge Stick გამღაბნელი ჩხირი Solid Color მყარი ფერი Spherize სფერიზება Spin Blur ბრუნვითი გაბუნდოვნება @@ -4230,6 +4231,11 @@ layerLabel Magenta მეწამული Working-cmyk სამუშაო CMYK Simulate Paper ქაღალდის იმიტაცია Warming Filter (85) გამათბობელი ფილტრი (85) + Warming Filter (LBA) გამათბობელი ფილტრი (LBA) + Warming Filter (81) გამათბობელი ფილტრი (81) + Cooling Filter (80) გამაციებელი ფილტრი (80) + Cooling Filter (LBB) გამაციებელი ფილტრი (LBB) + Cooling Filter (82) გამაციებელი ფილტრი (82) Cw საათის ისრის მიმართულებით Ccw საათის ისრის საწინააღმდეგოდ Nearest უახლოესი მეზობელი @@ -4442,46 +4448,72 @@ layerLabel Magenta მეწამული Right-to-left paragraph direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Not rendered: this adjustment ({key}) is kept as imported. არ არის დარენდერებული: ეს კორექცია ({key}) შენარჩუნებულია იმპორტირებული სახით. Print preview image ამობეჭდვამდე ნახვის გამოსახულება -# Update checks (Help menu and startup) - Automatically Check for Updates განახლებების ავტომატური შემოწმება + +# Strings added upstream (sync 2026-10-27) + 105% 105% + 110% 110% + 115% 115% + 120% 120% + From: საიდან: + Full Document მთელი დოკუმენტი + Merged Layers შერწყმული შრეები + Damaged Photoshop document დაზიანებული Photoshop-ის დოკუმენტი + The document “{name}” may be damaged. დოკუმენტი „{name}“ შეიძლება დაზიანებული იყოს. + Some layers, pixels or masks could not be read. Do you want to continue opening it? ზოგიერთი შრის, პიქსელის ან ნიღბის წაკითხვა ვერ მოხერხდა. გსურთ გახსნის გაგრძელება? + If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ და ორიგინალს გადააწერთ, წაუკითხავი მონაცემები შეიძლება სამუდამოდ დაიკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. + Alt-click a plane to set the clone source სიბრტყეზე Alt-დაწკაპუნებით მიუთითეთ კლონირების წყარო + Too many clone strokes: apply Vanishing Point first კლონირების შტრიხები ზედმეტად ბევრია: ჯერ გამოიყენეთ გაქრობის წერტილი + Calibration კალიბრაცია + Red Primary წითელი ძირითადი + Green Primary მწვანე ძირითადი + Blue Primary ლურჯი ძირითადი + Write xmp sidecars XMP თანმხლები ფაილების ჩაწერა + Set the tone sliders from the image ტონის სლაიდერების დაყენება გამოსახულების მიხედვით + Settings from {name} პარამეტრები {name}-დან + Settings from {name}; {count} not supported: {names} პარამეტრები {name}-დან; მხარდაუჭერელი ({count}): {names} + Could not write {name}: {error} {name}-ის ჩაწერა ვერ მოხერხდა: {error} + Kept {name}: written by another application {name} შენარჩუნებულია: ჩაწერილია სხვა აპლიკაციის მიერ + Shadow Tint ჩრდილების ტონირება Check for Updates… განახლებების შემოწმება… - Checking for updates… განახლებების შემოწმება… - Check Again ხელახლა შემოწმება - Could not check for updates. განახლებების შემოწმება ვერ მოხერხდა. - Installed version: {version} დაინსტალირებული ვერსია: {version} - Open Download Page ჩამოტვირთვის გვერდის გახსნა - Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ თქვენი სისტემის ინსტალატორი. - PhotoCraft {version} is available. ხელმისაწვდომია PhotoCraft {version}. - PhotoCraft is up to date. PhotoCraft განახლებულია. PhotoCraft Updates PhotoCraft-ის განახლებები + Checking for updates… განახლებების შემოწმება… + Open the release page to choose an installer for your system. გახსენით გამოშვების გვერდი და აირჩიეთ თქვენი სისტემის ინსტალატორი. + Open Download Page ჩამოტვირთვის გვერდის გახსნა + PhotoCraft is up to date. PhotoCraft განახლებულია. + Could not check for updates. განახლებების შემოწმება ვერ მოხერხდა. Updates have not been checked yet. განახლებები ჯერ არ შემოწმებულა. -# Color panel menu: slider modes, ramps and copy + Check Again ხელახლა შემოწმება + Automatically Check for Updates განახლებების ავტომატური შემოწმება + Installed version: {version} დაინსტალირებული ვერსია: {version} + PhotoCraft {version} is available. ხელმისაწვდომია PhotoCraft {version}. + Import Styles… სტილების იმპორტი… + Export Selected Styles… მონიშნული სტილების ექსპორტი… + Export Styles… სტილების ექსპორტი… + {file} is not a style library (.asl) {file} სტილების ბიბლიოთეკა არ არის (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file}-დან იმპორტირებულია სტილი: {n} +@plural Exported {n} style to {file}|Exported {n} styles to {file} სტილი: {n} ექსპორტირებულია {file}-ში + Hue Cube ფერის ტონის კუბი + Brightness Cube სიკაშკაშის კუბი + Color Wheel ფერთა წრე RGB Sliders RGB სლაიდერები HSB Sliders HSB სლაიდერები CMYK Sliders CMYK სლაიდერები Lab Sliders Lab სლაიდერები Web Color Sliders ვებფერის სლაიდერები - Hue Cube ფერის ტონის კუბი - Brightness Cube სიკაშკაშის კუბი - Color Wheel ფერთა წრე + Copy Color as HTML ფერის კოპირება HTML კოდად + Copy Color's Hex Code ფერის Hex კოდის კოპირება RGB Spectrum RGB სპექტრი CMYK Spectrum CMYK სპექტრი Grayscale Ramp ნაცრისფერი ტონების შკალა Current Colors მიმდინარე ფერები Make Ramp Web Safe ვებისთვის უსაფრთხო შკალა - Copy Color as HTML ფერის კოპირება HTML კოდად - Copy Color's Hex Code ფერის Hex კოდის კოპირება -# Styles panel: .asl style libraries - Import Styles… სტილების იმპორტი… - Export Styles… სტილების ექსპორტი… - Export Selected Styles… მონიშნული სტილების ექსპორტი… - {file} is not a style library (.asl) {file} არ არის სტილების ბიბლიოთეკა (.asl) -@plural Exported {n} style to {file}|Exported {n} styles to {file} სტილი: {n} ექსპორტირებულია {file}-ში -@plural Imported {n} style from {file}|Imported {n} styles from {file} {file}-დან იმპორტირებულია სტილი: {n} -# Damaged PSD/PSB prompt - Damaged Photoshop document დაზიანებული Photoshop-ის დოკუმენტი - The document “{name}” may be damaged. დოკუმენტი „{name}“ შესაძლოა დაზიანებული იყოს. - Some layers, pixels or masks could not be read. Do you want to continue opening it? ზოგიერთი შრის, პიქსელის ან ნიღბის წაკითხვა ვერ მოხერხდა. გსურთ, გააგრძელოთ გახსნა? - If you continue, saving over the original may permanently discard unreadable data. Save a copy to keep the original. თუ გააგრძელებთ და ორიგინალს გადააწერთ, წაუკითხავი მონაცემები შეიძლება სამუდამოდ დაიკარგოს. ორიგინალის შესანარჩუნებლად შეინახეთ ასლი. -# Select and Mask: the workspace's edits (select.refineEdge) - Edits ცვლილებები + Art History Brush Tool მხატვრული ისტორიის ფუნჯის ხელსაწყო + Tight Short მჭიდრო მოკლე + Tight Medium მჭიდრო საშუალო + Tight Long მჭიდრო გრძელი + Loose Medium თავისუფალი საშუალო + Loose Long თავისუფალი გრძელი + Dab ლაქა + Tight Curl მჭიდრო ხვია + Loose Curl თავისუფალი ხვია + Stylized dabs from a history state სტილიზებული ლაქები ისტორიის მდგომარეობიდან From 5ea00fe3f14e94a32ff1091eb94c8d160ad31ff1 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 28 Oct 2026 00:39:17 +0400 Subject: [PATCH 053/310] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 74785e9ffd..b5dfa21a06 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1850,7 +1850,7 @@ Full size tip სრული ზომის წვერი Gamut warning color ფერთა გამიდან გასვლის გაფრთხილების ფერი Gamut warning opacity ფერთა გამიდან გასვლის გაფრთხილების გაუმჭვირვალობა - Generator enabled გენერატორი ჩართულია + Enable Generator გენერატორის ჩართვა Gif GIF Glossy პრიალა Grid color ბადის ფერი @@ -4362,6 +4362,9 @@ layerLabel Magenta მეწამული Dissolve is approximated: it blends without Photoshop's dither. „გაფანტვა“ მიახლოებითია: შერევა Photoshop-ის დიზერინგის გარეშე ხდება. New Snapshot… ახალი სნეპშოტი… History Options… ისტორიის პარამეტრები… +# History panel › History Options… (#2966) + Allow Non-Linear History არაწრფივი ისტორიის დაშვება + Make Layer Visibility Changes Undoable შრის ხილვადობის ცვლილებებისთვის დაბრუნების ჩართვა Paints from the History panel's source state ხატავს ისტორიის პანელის წყარო მდგომარეობიდან Sets the source for the history brush ადგენს ისტორიის ფუნჯის წყაროს Bicubic Automatic ბიკუბური, ავტომატური 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 054/310] 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 e1d61ab659..1ac70db208 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 d3bcf2b688..e2ba88ca61 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 8e531b47b6..c1b592be49 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 4490d693c2..660ad19448 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 055/310] 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 1ac70db208..a1199ccea2 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 e2ba88ca61..d94f783a85 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 cdee0d6502..522ed46705 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 18c740ab80..8a7ea8ce8e 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 c1b592be49..ff32958bb0 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 056/310] 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 a1199ccea2..22c47b2960 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 d94f783a85..999ccb5700 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 ff32958bb0..9071bca7b1 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 057/310] 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 22c47b2960..48e8b26210 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 999ccb5700..b675e3d769 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 77339962cc12ccda3ea6df858b8c9fb210b06868 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 16:49:12 +0100 Subject: [PATCH 058/310] 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 3ef2d3940c..2c9078f9ff 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 573d1e7ec2..137864c30e 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 5fd82abeb5..eb929acfc7 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 059/310] 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 fb6f1ec883..d877e0270e 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 060/310] 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 eb8aeefacb..e7d91c4e82 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 9e565d0c31a6e7bd95c260aa167109462b06f437 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Fri, 30 Oct 2026 01:00:05 +0400 Subject: [PATCH 061/310] =?UTF-8?q?i18n(ka):=20Set=20Measurement=20Scale?= =?UTF-8?q?=20matches=20its=20menu=20item=20(=E1=83=A1=E1=83=90=E1=83=96?= =?UTF-8?q?=E1=83=9D=E1=83=9B=E1=83=98),=20as=20Slava=20suggested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index f3c5c332d6..10ca0f20dd 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3729,7 +3729,7 @@ layerLabel No Color ფერის გარეშე Search menus მენიუების ძიება Seed საწყისი რიცხვი Select and Mask მონიშვნა და ნიღაბი - Set Measurement Scale გაზომვის მასშტაბის დაყენება + Set Measurement Scale საზომი მასშტაბის დაყენება Shadows/Highlights ჩრდილები/ღია ტონები Shape Blur ფიგურით გაბუნდოვნება Shear დახრა From 763d193ecacba97788c3e4201584cf99c73db6c5 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 30 Oct 2026 22:41:02 +0700 Subject: [PATCH 062/310] 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 fe69f1ab33..ba5e2631f8 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 2a2d965f08..1be2b57427 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 f624746aa303795f85c7b97099e29fc7f59d3b88 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 12:47:18 -0700 Subject: [PATCH 063/310] Adjustments: Equalize's choices also open from ui.dialog.open and action playback With a selection, `open_command_dialog` opens the Equalize dialog, so `ui.dialog.open` and an action step with its dialog turned on offer both choices, as the menu does. The Equalize marker is a route marker, so `ui.dialog.set` cannot add it to another dialog. --- crates/ui-egui/src/dialogs.rs | 10 +++++++--- .../ui-egui/src/dialogs/equalize_ui/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/actions.rs | 7 ++++++- crates/ui-egui/src/panels/actions/tests.rs | 17 +++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c34e24dc10..54d5fa13bf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -845,6 +845,7 @@ const ROUTES: &[fn(&Fields) -> bool] = &[ crate::variables_ui::owns, crate::file_ui::owns, crate::new_guide_ui::owns, + crate::equalize_ui::owns, crate::color_picker_ui::owns, crate::color_range_ui::owns, crate::prefs_ui::owns, @@ -1061,9 +1062,9 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { Ok(Value::Null) } -/// Open the parameter dialog of `command`: the adjustment editor for `image.adjustments.*`, the -/// schema dialog for filters, the Color Range dialog for `select.colorRange`, otherwise a bare -/// confirm dialog. +/// Open the parameter dialog of `command`: the Equalize choices when there is a selection, the +/// adjustment editor for `image.adjustments.*`, the schema dialog for filters, the Color Range +/// dialog for `select.colorRange`, otherwise a bare confirm dialog. pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 { if command == "view.newGuideLayout" { return crate::guide_layout_ui::open(app); @@ -1071,6 +1072,9 @@ pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) if command == crate::stroke_ui::COMMAND { return crate::stroke_ui::open(app); } + if command == crate::equalize_ui::COMMAND && crate::equalize_ui::asks(app) { + return crate::equalize_ui::open(app); + } if crate::new_group_ui::fronts(command) { return crate::new_group_ui::open(app, command); } diff --git a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs index c0f7ef6006..58fcdf3774 100644 --- a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs @@ -27,6 +27,24 @@ fn gray(app: &PhotocraftApp, x: i32, y: i32) -> f32 { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[0] } +#[test] +fn command_dialog_with_a_selection_opens_equalize_choices() { + let (mut app, _) = setup(true); + let id = crate::dialogs::open_command_dialog(&mut app, COMMAND, "Equalize"); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert!(owns(fields)); + assert_eq!(fields[KEY], json!(true)); + + let (mut app, _) = setup(false); + let id = crate::dialogs::open_command_dialog(&mut app, COMMAND, "Equalize"); + assert!(!owns(&app.ui.dialog_mut(id).unwrap().fields)); +} + +#[test] +fn equalize_marker_is_a_route_marker() { + assert!(crate::dialogs::is_route_marker(MARK)); +} + #[test] fn equalize_with_a_selection_asks_and_defaults_to_the_selected_area_only() { let (mut app, ctx) = setup(true); diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index c1ba436d22..8402be2963 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -148,7 +148,12 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } // 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 action.dialogs.contains(&i) + && !app.automation_input + && (crate::filter_dialog::has_dialog(id) + || crate::adjust_dialog::has_dialog(id) + || (id == crate::equalize_ui::COMMAND && crate::equalize_ui::asks(app))) + { 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); diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index a2f7629513..1f4f5e5533 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -789,6 +789,23 @@ fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { assert!(v.get("paused").is_none()); } +#[test] +fn equalize_step_with_a_selection_opens_its_choices() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + let mut action = Action::new("Equalize", vec![(crate::equalize_ui::COMMAND.into(), json!({}))]); + action.dialogs.insert(0); + app.session.actions.list.push(action); + + let result = play(&mut app, &json!({"action": "Equalize"})).unwrap(); + let id = result["paused"]["dialog"].as_u64().unwrap(); + let fields = &app.ui.dialog_mut(id).unwrap().fields; + assert!(crate::equalize_ui::owns(fields)); + assert_eq!(fields["selectedAreaOnly"], true); +} + /// An 8×6 document, a Child action whose first step (a blur) shows its dialog and whose /// second step adds a "Suffix" layer, and recording into Owner started. fn recording_with_dialog_child() -> PhotocraftApp { From 894aeb1ddbc6a8fc7cffcffee187fe02817e345c Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 31 Oct 2026 04:23:06 +0400 Subject: [PATCH 064/310] i18n(ka): the 49 strings main added since (Select and Mask, histogram, pen, window menu) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 10ca0f20dd..a12ad5b084 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4551,3 +4551,53 @@ layerLabel Magenta მეწამული Font size presets შრიფტის ზომის პრესეტები Leading presets სტრიქონთა დაშორების პრესეტები Tracking presets ტრეკინგის პრესეტები +# Added with origin/main (2026-10-31): Select and Mask, histogram, pen, window menu + CPU Rendering CPU-რენდერინგი + Calculating… გამოითვლება… + Click to refresh histogram with uncached data დააწკაპუნეთ ჰისტოგრამის ქეშის გარეშე მონაცემებით განსაახლებლად + Color Aware ფერის გათვალისწინებით + Convert to Shape… ფიგურად გარდაქმნა… + Count: რაოდენობა: + Current Layer: მიმდინარე შრე: + Decontaminate Colors ფერების გაწმენდა + Delete Hidden Layers დამალული შრეების წაშლა + Delete effect ეფექტის წაშლა + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დააწკაპუნეთ მარჯვენა ღილაკით. + Drag to move the selected path გადაათრიეთ მონიშნული ტრაექტორიის გადასაადგილებლად + Drawn on the CPU: {reason}. Click for System Info. დახატულია CPU-ზე: {reason}. დააწკაპუნეთ სისტემის ინფორმაციის სანახავად. + Edge Detection კიდის აღმოჩენა + Fit Layer on Screen შრის ეკრანში ჩატევა + Flow: ნაკადი: + Global Refinements გლობალური დაზუსტებები + High Quality Preview მაღალი ხარისხის წინასწარი ნახვა + Level: დონე: + Move effect down ეფექტის ჩამოწევა + Move effect up ეფექტის აწევა + Object Aware ობიექტის გათვალისწინებით + Object Aware needs a segmentation model, which isn't available yet „ობიექტის გათვალისწინებით“ რეჟიმს სჭირდება სეგმენტაციის მოდელი, რომელიც ჯერ მიუწვდომელია + Path operations ტრაექტორიის ოპერაციები + Percentile: პროცენტილი: + Real-Time Refinement დაზუსტება რეალურ დროში + Refine Edge Brush Tool კიდის დაზუსტების ფუნჯის ხელსაწყო + Refine Edge… კიდის დაზუსტება… + Refine Hair თმის დაზუსტება + Refine Hair needs hair detection, which isn't available yet „თმის დაზუსტებას“ სჭირდება თმის აღმოჩენა, რომელიც ჯერ მიუწვდომელია + Refine Mode დაზუსტების რეჟიმი + Release Notes გამოშვების შენიშვნები + Remember Settings პარამეტრების დამახსოვრება + Reset Essentials ძირითადის საწყისზე დაბრუნება + Rounded Rectangle Tool მომრგვალებული მართკუთხედის ხელსაწყო + Select and Mask was closed: another layer was selected „მონიშვნა და ნიღაბი“ დაიხურა: მონიშნულია სხვა შრე + Select and Mask was closed: its document is no longer the active one „მონიშვნა და ნიღაბი“ დაიხურა: მისი დოკუმენტი აღარ არის აქტიური + Show Channels in Color არხების ფერად ჩვენება + Show Statistics სტატისტიკის ჩვენება + Source: წყარო: + Status Bar სტატუსის ზოლი + The path was discarded ტრაექტორია გაუქმდა + The path was kept as the Work Path ტრაექტორია შენარჩუნდა სამუშაო ტრაექტორიად + Tile Horizontally ჰორიზონტალურად განლაგება + Tile Vertically ვერტიკალურად განლაგება + Uncached Refresh განახლება ქეშის გარეშე + Update and Restart განახლება და გადატვირთვა + View Mode ხედის რეჟიმი + Select Layer Under Cursor კურსორის ქვეშ არსებული შრის მონიშვნა From 4a61951a379a58d6a4d1c8132ade17ecda17d536 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 31 Oct 2026 05:13:45 +0400 Subject: [PATCH 065/310] i18n(ka): No matching fonts; parity row count Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 1 + docs/localization-parity.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a12ad5b084..3708a28b39 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1232,6 +1232,7 @@ No character style სიმბოლოს სტილის გარეშე No document დოკუმენტი არ არის No matching commands შესაბამისი ბრძანება ვერ მოიძებნა + No matching fonts შესაბამისი შრიფტები არ არის No presets for the current tool. მიმდინარე ხელსაწყოს პრესეტები არ არის. No presets of this type. ამ ტიპის პრესეტები არ არის. No properties თვისებები არ არის diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ce1856cd4d..f285c18081 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -51,7 +51,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the -other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,412 rows; every measured key on 2026-10-24; +other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Georgian `ka` (4,530 rows; every measured key on 2026-10-31; Georgian UI fallback font: Noto Sans Georgian, Segoe UI, Sylfaen, SF Georgian, DejaVu Sans; on the web the first served font for `Geor`). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse From 0cd703f6f5b277c1e3b3099d012879727228cb5f Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sat, 31 Oct 2026 08:08:27 +0400 Subject: [PATCH 066/310] =?UTF-8?q?i18n(ka):=20Click=20is=20=E1=83=93?= =?UTF-8?q?=E1=83=90=E1=83=AC=E1=83=99=E1=83=90=E1=83=9E=E1=83=94=E1=83=97?= =?UTF-8?q?=20in=20three=20new=20rows=20(glossary)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3708a28b39..b4a040255b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4555,7 +4555,7 @@ layerLabel Magenta მეწამული # Added with origin/main (2026-10-31): Select and Mask, histogram, pen, window menu CPU Rendering CPU-რენდერინგი Calculating… გამოითვლება… - Click to refresh histogram with uncached data დააწკაპუნეთ ჰისტოგრამის ქეშის გარეშე მონაცემებით განსაახლებლად + Click to refresh histogram with uncached data დაწკაპეთ ჰისტოგრამის ქეშის გარეშე მონაცემებით განსაახლებლად Color Aware ფერის გათვალისწინებით Convert to Shape… ფიგურად გარდაქმნა… Count: რაოდენობა: @@ -4563,9 +4563,9 @@ layerLabel Magenta მეწამული Decontaminate Colors ფერების გაწმენდა Delete Hidden Layers დამალული შრეების წაშლა Delete effect ეფექტის წაშლა - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დააწკაპუნეთ მარჯვენა ღილაკით. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დაწკაპეთ მარჯვენა ღილაკით. Drag to move the selected path გადაათრიეთ მონიშნული ტრაექტორიის გადასაადგილებლად - Drawn on the CPU: {reason}. Click for System Info. დახატულია CPU-ზე: {reason}. დააწკაპუნეთ სისტემის ინფორმაციის სანახავად. + Drawn on the CPU: {reason}. Click for System Info. დახატულია CPU-ზე: {reason}. დაწკაპეთ სისტემის ინფორმაციის სანახავად. Edge Detection კიდის აღმოჩენა Fit Layer on Screen შრის ეკრანში ჩატევა Flow: ნაკადი: From ebc0e164f41e06b1262bac7b1cee80cc9ae9f11c Mon Sep 17 00:00:00 2001 From: Joe Date: Sat, 31 Oct 2026 12:37:20 +0700 Subject: [PATCH 067/310] 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 bf3b70d647..bc96de4cd4 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 f945d5df9b..e381efe058 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 cc41902216744b8df143f2bf0195385c18f3bcab Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:04:41 +0100 Subject: [PATCH 068/310] Window > Arrange: drag the borders between arranged documents (#10101) In 2-up, 3-up, 4-up, 6-up and the tiled layouts the border between two documents now drags, as in Photoshop: the documents either side take the new sizes, no cell goes narrower than 80 pt, and a border stops short of its neighbours. The positions are kept in ViewOptions::arrange_splits, checked before use (count, order, 0-1, finite) so a damaged or stale list lays out even cells; choosing an arrangement starts from even cells. Grabbing a border inside another document's edge no longer activates it. --- crates/ui-egui/src/canvas/tabs.rs | 38 +++- crates/ui-egui/src/menus/view_cmds.rs | 6 +- crates/ui-egui/src/menus/view_cmds/arrange.rs | 190 ++++++++++++++---- crates/ui-egui/src/menus/view_cmds/tests.rs | 136 ++++++++++++- 4 files changed, 323 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 2ab777b532..ab1b2f56a5 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -21,7 +21,7 @@ pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option = border_grips(app, rect).into_iter().map(|(_, grip)| grip).collect(); for (d, cell) in shown { let cell = cell.shrink(1.0); let view = app.ui.views[d].clone(); let v = canvas_view(app, ui, d, cell, view, d == active_doc); if d != active_doc { app.ui.views[d] = v; - if main_focused && ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p))) { + if main_focused + && ui.input(|i| { + i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| cell.contains(p) && !grips.iter().any(|g| g.contains(p))) + }) + { app.session.set_active(d); } } let stroke = if d == active_doc { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, Color32::from_black_alpha(160)) }; ui.painter().rect_stroke(cell, 0, stroke, egui::StrokeKind::Inside); } + drag_borders(app, ui, rect); return; } if idx != active_doc && main_focused && ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| rect.contains(p))) { @@ -107,6 +114,33 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { canvas_view(app, ui, idx, rect, view, app.session.active_index() == Some(idx)); } +/// Window › Arrange: the borders between the documents are dragged to give one more room, as in +/// Photoshop (#10101). Laid out after the canvases, so a grip over a cell's edge takes the press. +fn drag_borders(app: &mut PhotocraftApp, ui: &egui::Ui, rect: Rect) { + let n = docked_document_indices(app).len(); + let layout = app.ui.view.arrange.clone(); + for (border, grip) in border_grips(app, rect) { + let response = ui.interact(grip, ui.scope_id().with(("arrange-border", border.split)), Sense::drag()); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(if border.vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + } + if response.dragged() + && let Some(p) = response.interact_pointer_pos() + { + crate::view_cmds::drag_border(&layout, rect, n, &mut app.ui.view.arrange_splits, border.split, p); + } + } +} + +/// The arrangement's borders, each with the strip either side of it that grabs it. +fn border_grips(app: &PhotocraftApp, rect: Rect) -> Vec<(crate::view_cmds::Border, Rect)> { + let n = docked_document_indices(app).len(); + crate::view_cmds::borders(&app.ui.view.arrange, rect, n, &app.ui.view.arrange_splits) + .into_iter() + .map(|b| (b, b.line.expand2(if b.vertical { egui::vec2(3.0, 0.0) } else { egui::vec2(0.0, 3.0) }))) + .collect() +} + /// The tab strip; returns where its document tabs are. pub(super) fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let t = crate::theme::Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/menus/view_cmds.rs b/crates/ui-egui/src/menus/view_cmds.rs index bbe985ab6e..6194417a22 100644 --- a/crates/ui-egui/src/menus/view_cmds.rs +++ b/crates/ui-egui/src/menus/view_cmds.rs @@ -18,7 +18,7 @@ mod dialogs; mod dispatch; mod options; -pub use arrange::cells; +pub use arrange::{Border, borders, cells, drag_border}; pub(crate) use arrange::{document_is_floating, float_window}; pub(crate) use dialogs::write_copy; #[cfg(test)] @@ -106,6 +106,9 @@ pub struct ViewOptions { pub pixel_art_preview: bool, /// Window › Arrange layout of the document area: "tabs" or a tile/n-up layout id. pub arrange: String, + /// Where the borders between arranged documents were dragged (#10101): fractions of the + /// area, column borders then row borders; empty (or not fitting the layout) is even cells. + pub arrange_splits: Vec, /// Type › Font Preview Size: "small" | "medium" | "large" | "extraLarge" | "huge". pub font_preview_size: String, /// Type › Language Options: "defaultFeatures" | "eastAsianFeatures" | "middleEasternFeatures", @@ -138,6 +141,7 @@ impl Default for ViewOptions { pattern_preview: false, pixel_art_preview: false, arrange: "tabs".into(), + arrange_splits: Vec::new(), font_preview_size: "medium".into(), language_features: "defaultFeatures".into(), middle_eastern_composer: false, diff --git a/crates/ui-egui/src/menus/view_cmds/arrange.rs b/crates/ui-egui/src/menus/view_cmds/arrange.rs index 9cf7a61d80..e662ae14a1 100644 --- a/crates/ui-egui/src/menus/view_cmds/arrange.rs +++ b/crates/ui-egui/src/menus/view_cmds/arrange.rs @@ -51,6 +51,8 @@ pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize, f pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { if LAYOUTS.contains(&k) { app.ui.view.arrange = k.to_string(); + // A new arrangement starts from even cells. + app.ui.view.arrange_splits.clear(); // Each document fits its new cell. app.ui.views.iter_mut().for_each(|v| v.fit_pending = true); return Ok(json!({"arrange": k})); @@ -132,43 +134,157 @@ pub(crate) fn document_is_floating(app: &PhotocraftApp, doc: usize) -> bool { app.ui.windows.iter().any(|w| w.open && w.floating && w.document == doc) } -/// Screen rectangles for `n` documents in an arrangement (`None` = tabs). The active document -/// always gets a cell; n-up layouts show that many documents starting from the active one. -pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option> { - use craft_ui::layout::{SplitAxis, SplitSize, split_rect}; - - if n < 2 || !LAYOUTS.contains(&layout) { - return None; - } - let grid = |cols: usize, rows: usize, count: usize| -> Vec { - let (w, h) = (rect.width() / cols as f32, rect.height() / rows as f32); - (0..count.min(cols * rows)) - .map(|i| egui::Rect::from_min_size(rect.min + egui::vec2((i % cols) as f32 * w, (i / cols) as f32 * h), egui::vec2(w, h))) - .collect() - }; - Some(match layout { - "tileAllVertically" => grid(n, 1, n), - "tileAllHorizontally" => grid(1, n, n), - "twoUpVertical" => { - let panes = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![panes.first, panes.second] - } - "twoUpHorizontal" => { - let panes = split_rect(rect, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![panes.first, panes.second] - } - "threeUpVertical" => grid(3, 1, 3), - "threeUpHorizontal" => grid(1, 3, 3), - "threeUpStacked" => { - let columns = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?; - let rows = split_rect(columns.second, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?; - vec![columns.first, rows.first, rows.second] - } - "fourUp" => grid(2, 2, 4), - "sixUp" => grid(3, 2, 6), - _ => { - let cols = (n as f32).sqrt().ceil() as usize; - grid(cols, n.div_ceil(cols), n) +/// Screen rectangles for `n` documents in an arrangement (`None` = tabs), with the borders +/// where they were left (`splits`, [`ViewOptions::arrange_splits`](super::ViewOptions)). The +/// active document always gets a cell; n-up layouts show that many documents starting from the +/// active one. +pub fn cells(layout: &str, rect: egui::Rect, n: usize, splits: &[f32]) -> Option> { + let shape = Shape::of(layout, n)?; + let (xs, ys) = shape.lines(rect, splits); + Some(match shape { + Shape::Grid { cols, rows, count } => (0..count.min(cols * rows)) + .filter_map(|i| { + let (c, r) = (i % cols, i / cols); + Some(egui::Rect::from_min_max(egui::pos2(*xs.get(c)?, *ys.get(r)?), egui::pos2(*xs.get(c + 1)?, *ys.get(r + 1)?))) + }) + .collect(), + // One tall cell on the left; the right column is cut in two. + Shape::Stacked => { + let (x, y) = (xs.get(1).copied().unwrap_or(rect.center().x), ys.get(1).copied().unwrap_or(rect.center().y)); + vec![ + egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.max.y)), + egui::Rect::from_min_max(egui::pos2(x, rect.min.y), egui::pos2(rect.max.x, y)), + egui::Rect::from_min_max(egui::pos2(x, y), rect.max), + ] } }) } + +/// A border between arranged documents that can be dragged, as in Photoshop (#10101). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Border { + /// Which of the arrangement's splits it is: the index for [`drag_border`]. + pub split: usize, + /// It runs top to bottom (and moves left and right). + pub vertical: bool, + /// Its line on screen: zero wide (or tall) across the cells it divides. + pub line: egui::Rect, +} + +/// The borders of an arrangement, as laid out by [`cells`]. +pub fn borders(layout: &str, rect: egui::Rect, n: usize, splits: &[f32]) -> Vec { + let Some(shape) = Shape::of(layout, n) else { return Vec::new() }; + let (xs, ys) = shape.lines(rect, splits); + let inner = |v: &[f32]| v.get(1..v.len().saturating_sub(1)).unwrap_or_default().to_vec(); + let (inner_x, inner_y) = (inner(&xs), inner(&ys)); + let vertical = inner_x.iter().enumerate().map(|(i, &x)| Border { + split: i, + vertical: true, + line: egui::Rect::from_min_max(egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)), + }); + // 3-up Stacked's row border spans only the right column. + let left = match shape { + Shape::Stacked => inner_x.first().copied().unwrap_or(rect.min.x), + Shape::Grid { .. } => rect.min.x, + }; + let horizontal = inner_y.iter().enumerate().map(|(i, &y)| Border { + split: inner_x.len() + i, + vertical: false, + line: egui::Rect::from_min_max(egui::pos2(left, y), egui::pos2(rect.max.x, y)), + }); + vertical.chain(horizontal).collect() +} + +/// Move border `split` of the arrangement to `pointer` (screen points), keeping every cell at +/// least [`MIN_CELL`] points across where the area allows. `splits` is rewritten in full, so a +/// stale or damaged list starts again from even cells. +pub fn drag_border(layout: &str, rect: egui::Rect, n: usize, splits: &mut Vec, split: usize, pointer: egui::Pos2) { + let Some(shape) = Shape::of(layout, n) else { return }; + let (cols, rows) = shape.counts(); + let mut fractions = shape.fractions(splits); + // The borders on the dragged one's axis are `fractions[first..first + count]`. + let (axis_start, axis_len, first, count, pos) = if split < cols - 1 { + (rect.min.x, rect.width(), 0, cols - 1, pointer.x) + } else if split < (cols - 1) + (rows - 1) { + (rect.min.y, rect.height(), cols - 1, rows - 1, pointer.y) + } else { + return; + }; + if !(axis_len > 0.0 && pos.is_finite()) { + return; + } + let own = fractions.get(first..first + count).map(<[f32]>::to_vec).unwrap_or_default(); + let k = split - first; + let gap = (MIN_CELL / axis_len).min(0.5 / own.len().saturating_add(1) as f32); + let lo = if k == 0 { 0.0 } else { own.get(k - 1).copied().unwrap_or(0.0) } + gap; + let hi = own.get(k + 1).copied().unwrap_or(1.0) - gap; + if let Some(f) = fractions.get_mut(split) { + *f = ((pos - axis_start) / axis_len).clamp(lo.min(hi), hi.max(lo)); + } + *splits = fractions; +} + +/// The narrowest a cell is dragged to, in points. +pub const MIN_CELL: f32 = 80.0; + +/// An arrangement as columns and rows of cells. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Shape { + /// `cols` × `rows` even cells, the first `count` of them used. + Grid { cols: usize, rows: usize, count: usize }, + /// 3-up Stacked: two columns, the right one in two rows. + Stacked, +} + +impl Shape { + fn of(layout: &str, n: usize) -> Option { + if n < 2 || !LAYOUTS.contains(&layout) { + return None; + } + let grid = |cols: usize, rows: usize, count: usize| Shape::Grid { cols, rows, count }; + Some(match layout { + "tileAllVertically" => grid(n, 1, n), + "tileAllHorizontally" => grid(1, n, n), + "twoUpVertical" => grid(2, 1, 2), + "twoUpHorizontal" => grid(1, 2, 2), + "threeUpVertical" => grid(3, 1, 3), + "threeUpHorizontal" => grid(1, 3, 3), + "threeUpStacked" => Shape::Stacked, + "fourUp" => grid(2, 2, 4), + "sixUp" => grid(3, 2, 6), + _ => { + let cols = (n as f32).sqrt().ceil() as usize; + grid(cols, n.div_ceil(cols), n) + } + }) + } + + fn counts(self) -> (usize, usize) { + match self { + Shape::Grid { cols, rows, .. } => (cols, rows), + Shape::Stacked => (2, 2), + } + } + + /// The split positions as fractions: the column borders, then the row borders. `splits` + /// is used only when it fits this shape (as many values, each axis increasing within 0–1); + /// otherwise the cells are even. + fn fractions(self, splits: &[f32]) -> Vec { + let (cols, rows) = self.counts(); + let even = |k: usize| (1..k).map(move |i| i as f32 / k as f32); + let ordered = |v: &[f32]| v.iter().all(|f| f.is_finite() && *f > 0.0 && *f < 1.0) && v.windows(2).all(|w| matches!(w, [a, b] if a < b)); + let fit = splits.len() == (cols - 1) + (rows - 1) && splits.get(..cols - 1).is_some_and(ordered) && splits.get(cols - 1..).is_some_and(ordered); + if fit { splits.to_vec() } else { even(cols).chain(even(rows)).collect() } + } + + /// Every column line's x and every row line's y on screen, the area's edges included. + fn lines(self, rect: egui::Rect, splits: &[f32]) -> (Vec, Vec) { + let (cols, _) = self.counts(); + let fractions = self.fractions(splits); + let (fx, fy) = fractions.split_at((cols - 1).min(fractions.len())); + let at = |start: f32, len: f32, inner: &[f32]| { + std::iter::once(start).chain(inner.iter().map(|f| start + len * f)).chain(std::iter::once(start + len)).collect::>() + }; + (at(rect.min.x, rect.width(), fx), at(rect.min.y, rect.height(), fy)) + } +} diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index 3ef2ab13fa..d6de7d3cb5 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -160,14 +160,14 @@ fn print_size_uses_the_screen_resolution_preference() { #[test] fn arrange_layouts_floating_windows_and_matching() { let (mut app, ctx) = app_with(3); - assert!(cells("tabs", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).is_none()); + assert!(cells("tabs", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).is_none()); menu(&mut app, &ctx, "window.arrange.threeUpStacked", json!({})).unwrap(); assert_eq!(checked(&app, "window.arrange.threeUpStacked"), Some(true)); - let c = cells(&app.ui.view.arrange, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).unwrap(); + let c = cells(&app.ui.view.arrange, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).unwrap(); assert_eq!(c.len(), 3); assert_eq!(c[0].height(), 600.0); assert_eq!(c[1].height(), 300.0); - let six = cells("sixUp", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3).unwrap(); + let six = cells("sixUp", egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)), 3, &[]).unwrap(); assert_eq!((six.len(), six[1].min.x), (6, 300.0)); menu(&mut app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); assert_eq!(app.ui.windows.len(), 3); @@ -209,13 +209,91 @@ fn match_zoom_clamps_to_each_documents_zoom_range() { fn two_up_and_stacked_cells_keep_their_existing_boundaries() { let rect = egui::Rect::from_min_max(egui::pos2(40.0, 75.0), egui::pos2(940.0, 675.0)); let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - assert_eq!(cells("twoUpVertical", rect, 2).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]); - assert_eq!(cells("twoUpHorizontal", rect, 2).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]); + assert_eq!(cells("twoUpVertical", rect, 2, &[]).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 675.0)]); + assert_eq!(cells("twoUpHorizontal", rect, 2, &[]).unwrap(), vec![region(40.0, 75.0, 940.0, 375.0), region(40.0, 375.0, 940.0, 675.0)]); assert_eq!( - cells("threeUpStacked", rect, 3).unwrap(), + cells("threeUpStacked", rect, 3, &[]).unwrap(), vec![region(40.0, 75.0, 490.0, 675.0), region(490.0, 75.0, 940.0, 375.0), region(490.0, 375.0, 940.0, 675.0)] ); - assert!(cells("twoUpVertical", rect, 1).is_none()); + assert!(cells("twoUpVertical", rect, 1, &[]).is_none()); +} + +#[test] +fn dragged_arrange_borders_resize_the_cells_beside_them() { + // #10101: in 2-up and the other arrangements the border between documents drags, as in + // Photoshop, and the documents on either side take the new sizes. + use super::arrange::{Border, MIN_CELL}; + let near = |a: &[egui::Rect], b: &[egui::Rect]| { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01) + }; + let rect = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(900.0, 600.0)); + let region = |left, top, right, bottom| egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + let mut splits = Vec::new(); + let b = borders("twoUpVertical", rect, 2, &splits); + assert_eq!(b, vec![Border { split: 0, vertical: true, line: region(450.0, 0.0, 450.0, 600.0) }]); + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(300.0, 250.0)); + assert!(near(&cells("twoUpVertical", rect, 2, &splits).unwrap(), &[region(0.0, 0.0, 300.0, 600.0), region(300.0, 0.0, 900.0, 600.0)])); + assert!((borders("twoUpVertical", rect, 2, &splits)[0].line.min.x - 300.0).abs() < 0.01); + // No cell is dragged narrower than MIN_CELL, nor past the area. + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(-500.0, 0.0)); + assert!((cells("twoUpVertical", rect, 2, &splits).unwrap()[0].width() - MIN_CELL).abs() < 0.01); + drag_border("twoUpVertical", rect, 2, &mut splits, 0, egui::pos2(899.0, 0.0)); + assert!((cells("twoUpVertical", rect, 2, &splits).unwrap()[1].width() - MIN_CELL).abs() < 0.01); + + // 4-up: a column border and a row border, each across the whole area. + let mut splits = Vec::new(); + let b = borders("fourUp", rect, 4, &splits); + assert_eq!(b.iter().map(|b| (b.split, b.vertical)).collect::>(), vec![(0, true), (1, false)]); + drag_border("fourUp", rect, 4, &mut splits, 1, egui::pos2(10.0, 450.0)); + let c = cells("fourUp", rect, 4, &splits).unwrap(); + assert!(near(&[c[0], c[3]], &[region(0.0, 0.0, 450.0, 450.0), region(450.0, 450.0, 900.0, 600.0)])); + + // 3-up Stacked: the row border is only across the right column, and follows the column. + let mut splits = Vec::new(); + drag_border("threeUpStacked", rect, 3, &mut splits, 0, egui::pos2(600.0, 0.0)); + drag_border("threeUpStacked", rect, 3, &mut splits, 1, egui::pos2(700.0, 200.0)); + assert!(near( + &cells("threeUpStacked", rect, 3, &splits).unwrap(), + &[region(0.0, 0.0, 600.0, 600.0), region(600.0, 0.0, 900.0, 200.0), region(600.0, 200.0, 900.0, 600.0)] + )); + assert!(near(&[borders("threeUpStacked", rect, 3, &splits)[1].line], &[region(600.0, 200.0, 900.0, 200.0)])); + + // Tiled: a border stops short of its neighbours, so the cells keep their order. + let mut splits = Vec::new(); + drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(800.0, 0.0)); + let c = cells("threeUpVertical", rect, 3, &splits).unwrap(); + assert!((c[0].max.x - (600.0 - MIN_CELL)).abs() < 0.01 && (c[1].width() - MIN_CELL).abs() < 0.01); +} + +#[test] +fn damaged_or_stale_arrange_splits_lay_out_even_cells() { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)); + let even = cells("threeUpVertical", rect, 3, &[]).unwrap(); + // From a settings file or another layout: wrong count, out of order, outside 0–1, NaN. + for bad in [vec![0.5], vec![0.6, 0.3], vec![0.0, 0.5], vec![0.3, 1.5], vec![f32::NAN, 0.7], vec![0.2, 0.4, 0.6]] { + assert_eq!(cells("threeUpVertical", rect, 3, &bad).unwrap(), even, "{bad:?}"); + assert_eq!(borders("threeUpVertical", rect, 3, &bad).len(), 2); + // A drag starts again from even cells. + let mut splits = bad.clone(); + drag_border("threeUpVertical", rect, 3, &mut splits, 1, egui::pos2(700.0, 0.0)); + assert_eq!(cells("threeUpVertical", rect, 3, &splits).unwrap()[0], even[0], "{bad:?}"); + } + // A split that isn't the layout's, or a pointer off the numbers, changes nothing. + let mut splits = vec![0.25, 0.5]; + drag_border("threeUpVertical", rect, 3, &mut splits, 2, egui::pos2(700.0, 0.0)); + drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(f32::NAN, 0.0)); + drag_border("threeUpVertical", egui::Rect::NOTHING, 3, &mut splits, 0, egui::pos2(10.0, 0.0)); + assert_eq!(splits, vec![0.25, 0.5]); + assert!(borders("tabs", rect, 3, &[]).is_empty()); +} + +#[test] +fn choosing_an_arrangement_starts_from_even_cells() { + let (mut app, ctx) = app_with(2); + menu(&mut app, &ctx, "window.arrange.twoUpVertical", json!({})).unwrap(); + app.ui.view.arrange_splits = vec![0.3]; + menu(&mut app, &ctx, "window.arrange.twoUpHorizontal", json!({})).unwrap(); + assert!(app.ui.view.arrange_splits.is_empty()); } #[test] @@ -749,6 +827,50 @@ fn arranged_documents_keep_their_tiles() { assert_eq!(docs(&mut app, 1), vec![0, 1]); } +/// #10101: dragging the border between two arranged documents moves it. Grabbed inside the +/// other document's edge, it doesn't make that document the active one. +#[test] +fn dragging_the_border_between_arranged_documents_resizes_them() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = crate::state::Tool::Brush; + // The right-hand document is active; the grab below is inside the left one's edge. + app.session.set_active(1); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let area = h.state().last_canvas_rect; + let n = h.state().session.documents().len(); + let border = borders("twoUpVertical", area, n, &[])[0].line; + // On the grip, 2 pt inside the left document (its cell ends 1 pt short of the border). + let from = border.center() - egui::vec2(2.0, 0.0); + let to = from - egui::vec2(200.0, 0.0); + let ev = |h: &mut Harness<'_, PhotocraftApp>, e: egui::Event| h.input_mut().events.push(e); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + ev(&mut h, egui::Event::PointerMoved(from)); + h.run(); + ev(&mut h, button(from, true)); + h.run(); + for step in 1..=10 { + ev(&mut h, egui::Event::PointerMoved(from + (to - from) * (step as f32 / 10.0))); + h.run(); + } + ev(&mut h, button(to, false)); + h.run_steps(2); + let cells = cells("twoUpVertical", area, n, &h.state().ui.view.arrange_splits).unwrap(); + assert!((cells[0].max.x - to.x).abs() < 1.0, "the border follows the pointer: {cells:?}, pointer at {}", to.x); + assert_eq!(h.state().session.active_index(), Some(1), "grabbing the border activates no document"); +} + /// #6017: rulers showed in the active tile only; the other tile had none and no room for them. #[test] fn every_arranged_tile_has_rulers() { From 02ab616249c0da1d56af397fa75b91d5ee7a223f Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:11:52 +0100 Subject: [PATCH 069/310] Curvature Pen: carry an open path on from its selected endpoint (#10726) After another tool, the Curvature Pen could not continue an open path: a click on its endpoint only selected it and the next click started a new path. As in Photoshop, with an endpoint of an open path selected (a click or Ctrl-click on it) the next click now carries that path on from there, and the commit extends the same subpath in one History step. The resumed knots are kept in PenPath::fixed and curvature_refresh puts them back after each pass, so the existing segments keep their shape; only the endpoint's outgoing handle (and, once closed, the first knot's incoming one) follows the new anchors. Old anchors don't move while drawing on. --- crates/ui-egui/src/pen_variants_tests.rs | 48 +++++++++++++++++++ crates/ui-egui/src/tools/direct_select.rs | 12 +++++ crates/ui-egui/src/tools/vector_ui.rs | 5 ++ .../ui-egui/src/tools/vector_ui/curvature.rs | 34 ++++++++++++- crates/ui-egui/src/tools/vector_ui/pen.rs | 2 +- 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 4b5cf04d61..d4d4910eab 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -210,6 +210,54 @@ fn the_pen_carries_on_an_open_path_from_either_end() { assert_eq!((work.subpaths.len(), work.subpaths[1].closed, work.subpaths[1].knots.len()), (2, true, 3)); } +/// #10726: after another tool, the Curvature Pen carries an open path on as in Photoshop: a click on +/// its endpoint selects it, and the next click continues the path from there. The subpath is +/// extended in one History step, its segments keep their shape, and the new anchors take smooth +/// curvature handles. +#[test] +fn the_curvature_pen_carries_on_an_open_path() { + let mut app = app(Tool::Pen); + click(&mut app, 20.0, 20.0); + // A curve: the middle anchor's handles dragged out further than the Curvature Pen would set. + tool_event(&mut app, ToolEvent::Down { x: 80.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 130.0, y: 10.0 }, Modifiers::NONE); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let before = work_path(&app).subpaths[0].knots.clone(); + assert_ne!(before[1].out_ctrl, before[1].anchor, "the middle anchor has handles"); + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + let at = steps(&app); + app.ui.tool = Tool::CurvaturePen; + click(&mut app, 80.0, 80.0); + assert!(app.ui.pen.is_none(), "the click selects the endpoint, it draws nothing"); + click(&mut app, 140.0, 120.0); + assert!(app.ui.pen.as_ref().is_some_and(|p| p.curvature && p.knots.len() == 4), "the next click carries the path on"); + click(&mut app, 200.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + let knots = &work.subpaths[0].knots; + let anchors: Vec<[f64; 2]> = knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, [[20.0, 20.0], [80.0, 20.0], [80.0, 80.0], [140.0, 120.0], [200.0, 80.0]]); + assert_eq!(steps(&app), at + 1, "one History step"); + // The Pen's straight segments keep their shape: the first knots are as they were, and the + // endpoint keeps its incoming handle. + assert_eq!((knots[0], knots[1]), (before[0], before[1])); + assert_eq!((knots[2].anchor, knots[2].in_ctrl), (before[2].anchor, before[2].in_ctrl)); + // The new anchor in the middle is smooth: its handles lie either side of it. + let mid = &knots[3]; + assert!(mid.in_ctrl != mid.anchor && mid.out_ctrl != mid.anchor, "{mid:?}"); + assert!((mid.in_ctrl.x - mid.anchor.x) * (mid.out_ctrl.x - mid.anchor.x) < 0.0, "handles on either side: {mid:?}"); + // While it draws on, the Curvature Pen moves only the new anchors: a press on an old corner + // adds an anchor there. + click(&mut app, 200.0, 80.0); + click(&mut app, 260.0, 40.0); + click(&mut app, 80.0, 20.0); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(7), "a new anchor, the old one untouched"); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots[1][0]), Some([80.0, 20.0])); +} + /// #6593 review: carrying on a subpath keeps its path operation, and a draft whose subpath changed /// meanwhile refuses without overwriting newer work. The historical test name is retained /// for reviewed-head provenance; captured-revision validation supersedes its stale fallback. diff --git a/crates/ui-egui/src/tools/direct_select.rs b/crates/ui-egui/src/tools/direct_select.rs index 86df553ddc..231307e5ac 100644 --- a/crates/ui-egui/src/tools/direct_select.rs +++ b/crates/ui-egui/src/tools/direct_select.rs @@ -241,6 +241,18 @@ fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool { !mods.command && (tool == Tool::ConvertPoint || (tool == Tool::Pen && mods.alt && pen_idle)) } +/// The selected anchor when it is the only one and an endpoint of an open subpath of the active +/// path: where the Curvature Pen carries the path on from (#10726), as Photoshop does after a +/// click, or ⌘/Ctrl-click, on the last point. +pub(crate) fn selected_open_end(app: &PhotocraftApp) -> Option<[f64; 2]> { + let ds = &app.ui.direct_selection; + let ([[s, k]], Some(target)) = (ds.anchors.as_slice(), ds.target) else { return None }; + let (_, path) = active_paths(app).into_iter().find(|(r, _)| *r == target)?; + let sub = path.subpaths.get(*s).filter(|sub| !sub.closed)?; + let knot = sub.knots.get(*k).filter(|_| *k == 0 || *k + 1 == sub.knots.len())?; + Some([knot.anchor.x, knot.anchor.y]) +} + /// Is the Curvature Pen idle (no path being drawn, no ⌘/Ctrl)? Then it edits the active path in /// place, as in Photoshop: a press on one of its anchors drags it, with no modifier. fn curvature_idle(app: &PhotocraftApp, mods: Modifiers) -> bool { diff --git a/crates/ui-egui/src/tools/vector_ui.rs b/crates/ui-egui/src/tools/vector_ui.rs index 72ec518f81..5db7968763 100644 --- a/crates/ui-egui/src/tools/vector_ui.rs +++ b/crates/ui-egui/src/tools/vector_ui.rs @@ -100,6 +100,11 @@ pub struct PenPath { /// commit replaces that subpath instead of adding one. #[serde(default)] pub resumes: Option, + /// The Curvature Pen carrying on a path: its knots as they were, which keep their shape while + /// the new anchors' handles follow them (`curvature_refresh`, #10726). The last one is the + /// endpoint drawing carries on from; only its outgoing handle bends into the new segment. + #[serde(default)] + pub fixed: Vec<[[f64; 2]; 3]>, } /// The subpath a Pen path carries on: where it is and what it was when drawing resumed. The diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index ebd62becdb..d09dc6947a 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -1,11 +1,27 @@ -use super::pen::{cancel_stale, pen_commit}; +use super::pen::{cancel_stale, pen_commit, resume_at}; use super::{PenPath, PhotocraftApp}; -/// Recompute a Curvature Pen path's handles from its anchors and corner points. +/// Recompute a Curvature Pen path's handles from its anchors and corner points. The knots of a +/// path it carries on (`PenPath::fixed`) keep theirs, but for the endpoint's outgoing handle, +/// which bends into the first new segment (#10726), and, once the path closes, the first knot's +/// incoming handle, which bends into the closing one. pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); let corners: Vec = (0..anchors.len()).map(|i| pen.unlinked.contains(&i)).collect(); pen.knots = photocraft_vector::edit::curvature_knots(&anchors, &corners, closed); + let last = pen.fixed.len().saturating_sub(1); + let extended = pen.knots.len() > pen.fixed.len(); + for (i, was) in pen.fixed.iter().enumerate() { + let Some(k) = pen.knots.get_mut(i) else { break }; + let (keep_in, keep_out) = (!(closed && i == 0), !(extended && i == last)); + k[0] = was[0]; + if keep_in { + k[1] = was[1]; + } + if keep_out { + k[2] = was[2]; + } + } } /// The anchor of the Curvature Pen path being drawn under (x, y), for the cursor. @@ -44,6 +60,20 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { if app.ui.pen.is_none() { app.ui.pen_doc = app.session.active().map(|st| st.doc.id); } + // With an endpoint of an open path selected (a click, or ⌘/Ctrl-click, on it), the press + // carries that path on from there, as in Photoshop (#10726): its segments keep their shape, + // the press adds the next anchor and the commit extends that subpath. + if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) + && let Some(end) = crate::direct_select::selected_open_end(app) + && let Some(mut resumed) = resume_at(app, end[0], end[1], 0.5) + { + resumed.curvature = true; + resumed.fixed = resumed.knots.clone(); + app.ui.pen = Some(resumed); + } + // The anchors of the path carried on stay where they are; the new ones move. + let fixed = app.ui.pen.as_ref().map_or(0, |p| p.fixed.len()); + let hit = hit.filter(|&i| i >= fixed); let Some(owner) = capture_pen_owner(app) else { return }; let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); if pen.owner.is_none() { diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index baa536f3b8..bf5a742b86 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -479,7 +479,7 @@ fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { /// layer, else of the path a new Pen path would join (the one selected in the Paths panel, else /// 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 { +pub(super) 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, layer, path) = if let Some((id, p)) = targeted_vector_mask(app) { From 42ab92c6229963b75f73b11b0c2ec8d11efd3c46 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 14:54:35 -0800 Subject: [PATCH 070/310] 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 42a4722479..1b1af71e61 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 78d65979bf..fe89ab2dfb 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 0000000000..7eabb775df --- /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 0000000000..d0f5c108e8 --- /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 f6a8360a4f..382287a9c7 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 d38a75f101..e1f57e1065 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 0000000000..4f02ec2673 --- /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 e8d8b334ba..eb6c9f1933 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 071/310] 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 1f9cce3431..5290299ae5 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 1b1af71e61..a7e38f9300 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 fe89ab2dfb..d2fe8571de 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 0000000000..e5c97d294d --- /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 382287a9c7..7528a8c580 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 5683afd88a..f096ef4952 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 0000000000..a9f9aeb206 --- /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 eb6c9f1933..61be475d52 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 072/310] 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 5290299ae5..eecc287c29 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 0000000000..c196b7b22f --- /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 7528a8c580..ddfe294a94 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 3c7b22dda0..4da9a19b9a 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 e1f57e1065..99b11c7ed8 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 f096ef4952..d0a7c89968 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 0000000000..275beb4962 --- /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 0000000000..a59eaeedce --- /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 61be475d52..7e1fbbc4ff 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 073/310] 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 c196b7b22f..5b6bafd0fc 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 e5c97d294d..31907b7ced 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 d0f5c108e8..ee914cf41e 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 a59eaeedce..46476135dc 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 a9f9aeb206..f577e4a2c0 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 4f02ec2673..d87c92323d 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 074/310] 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 a60cc66a16..8c5f3efcda 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 e4c710f281..7783825d38 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 a7e38f9300..5dbb886383 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 be7bd45151..e9001da11b 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 0000000000..12ee95a26e --- /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 ddfe294a94..f429271845 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 14038b10de..ac62672c13 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 99b11c7ed8..4448f86f2c 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 e17b2bc80d..4f67598531 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 d0a7c89968..48a336431a 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 0000000000..9d12c0f401 --- /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 0000000000..a969da46d4 --- /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 7e1fbbc4ff..b06a9094d2 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 075/310] 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 ea68d86b50..29543b983e 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 a0cc11e64e..d046f6918a 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 0000000000..9be10d94ad --- /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 0000000000..b6049967bb --- /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 a617fffbc9..3d15b013da 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 7eabb775df..d982cec997 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 5de5928052..c6e500c4b4 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 ac9e38a723..913c5f1841 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 0000000000..8ba9e42544 --- /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 0000000000..9e3b02367b --- /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 ee914cf41e..6b6193b4c6 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 f429271845..1f5ad2f142 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 ac62672c13..1a3058187d 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 a7d3f8dc94..f3ad6052ff 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 f0da8f88de..e7351ef476 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 5d060f2c6a..0c9f62ea0b 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 0f87c26faf..e810d0086d 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 4f67598531..61252d9dc1 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 05747cbb0f..22883f8ba7 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 87d420e2bc..e629a7b1b1 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 0000000000..666f6e101f --- /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 0000000000..4cdedeeefb --- /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 c339a921cf..c79f12df3c 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 0000000000..5f015e885d --- /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 0000000000..a29feb358f --- /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 0000000000..dc91c67600 --- /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 b06a9094d2..bd3fe819ad 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 277d20b954..3c0eb6ce34 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 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 076/310] 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 1f6090025c..a06c1dc800 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 4d063494d8224de0fbb2c07b4e605db0d96a5a90 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:52:11 +0000 Subject: [PATCH 077/310] #10101: the grip takes the press, and the minimum holds on a short axis Three review findings: - A grip overlaps the active document's edge, where the press started a brush stroke as well as the drag. The frame now records that the press began on a grip (`arrange_grip_press`) and the canvases ignore it, the way the inactive-document path already did. - `drag_border` capped the gap at half the even spacing, so on a 300 pt axis a 2-up cell stopped at 75 pt although 80 would have fitted. The cap is gone; the limit is MIN_CELL itself. - With the neighbours closer together than two minimum cells, the clamp wrote a value from the wrong side of them and put the list out of order. That case now leaves the border, and the list, as they were. The tests cover the short axis and both directions of the no-room case, and each fails with its own change reverted. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/canvas.rs | 4 +++- crates/ui-egui/src/canvas/tabs.rs | 6 +++++- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/menus/view_cmds/arrange.rs | 11 +++++++++-- crates/ui-egui/src/menus/view_cmds/tests.rs | 18 ++++++++++++++++++ 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c1660d75f9..b5101c1fc5 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -94,6 +94,7 @@ impl PhotocraftApp { fonts_ready: false, last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), arrange_start: 0, + arrange_grip_press: false, ppp: 1.0, drop_canvas_rect: None, tab_strip: None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3196f4951f..71cce10d95 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1792,7 +1792,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // never maps through a camera that is still moving under the pointer. let (pointer_here, hover_here) = ctx.input(|i| { ( - (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| rect.contains(p)), + // A press that began on an arrangement's border grip belongs to that border, not to + // the canvas it overlaps (#10101 review). + !app.arrange_grip_press && (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| rect.contains(p)), i.pointer.hover_pos().is_some_and(|p| rect.contains(p)), ) }); diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index ab1b2f56a5..e3b2471f43 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -85,8 +85,12 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(shown) = arranged_cells(app, rect) { app.arrange_start = shown.first().map_or(0, |(d, _)| *d); let t = crate::theme::Tokens::get(ui.ctx()); - // A press on a border grip drags the border (`drag_borders`); it activates no document. + // A press on a border grip drags the border (`drag_borders`): it activates no document, + // and the canvas under it takes no tool input from it either — a grip overlaps the + // active document's edge, where a press would otherwise start a stroke (#10101 review). let grips: Vec = border_grips(app, rect).into_iter().map(|(_, grip)| grip).collect(); + app.arrange_grip_press = + ui.input(|i| (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| grips.iter().any(|g| g.contains(p)))); for (d, cell) in shown { let cell = cell.shrink(1.0); let view = app.ui.views[d].clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50b0174631..2a8cd5b199 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -465,6 +465,9 @@ pub struct PhotocraftApp { /// Window › Arrange: the first document of the shown run of tiles, when there are more /// documents than tiles (`canvas::arranged_cells`). pub(crate) arrange_start: usize, + /// Window › Arrange: this frame's pointer press began on a border grip, so the canvases take + /// no tool input from it — the press drags the border (`canvas::tabs::drag_borders`, #10101). + pub(crate) arrange_grip_press: bool, /// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`, which /// folds in both the display scale and Interface › UI Scale). The canvas maps document pixels /// to physical pixels, so point-space geometry divides the view zoom by this (see diff --git a/crates/ui-egui/src/menus/view_cmds/arrange.rs b/crates/ui-egui/src/menus/view_cmds/arrange.rs index e662ae14a1..04ec1600b8 100644 --- a/crates/ui-egui/src/menus/view_cmds/arrange.rs +++ b/crates/ui-egui/src/menus/view_cmds/arrange.rs @@ -215,11 +215,18 @@ pub fn drag_border(layout: &str, rect: egui::Rect, n: usize, splits: &mut Vec::to_vec).unwrap_or_default(); let k = split - first; - let gap = (MIN_CELL / axis_len).min(0.5 / own.len().saturating_add(1) as f32); + // [`MIN_CELL`] in fractions of this axis, whatever the number of cells: capping it by the + // even spacing would let a cell fall under the minimum on a long axis (#10101 review). + let gap = MIN_CELL / axis_len; let lo = if k == 0 { 0.0 } else { own.get(k - 1).copied().unwrap_or(0.0) } + gap; let hi = own.get(k + 1).copied().unwrap_or(1.0) - gap; + // The neighbours leave no room for a cell of the minimum width: the border stays where it is, + // rather than taking a value out of order that would invalidate the whole list. + if lo > hi { + return; + } if let Some(f) = fractions.get_mut(split) { - *f = ((pos - axis_start) / axis_len).clamp(lo.min(hi), hi.max(lo)); + *f = ((pos - axis_start) / axis_len).clamp(lo, hi); } *splits = fractions; } diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index d6de7d3cb5..ddf9a11729 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -263,6 +263,24 @@ fn dragged_arrange_borders_resize_the_cells_beside_them() { drag_border("threeUpVertical", rect, 3, &mut splits, 0, egui::pos2(800.0, 0.0)); let c = cells("threeUpVertical", rect, 3, &splits).unwrap(); assert!((c[0].max.x - (600.0 - MIN_CELL)).abs() < 0.01 && (c[1].width() - MIN_CELL).abs() < 0.01); + + // #10101 review: the limit is MIN_CELL itself, not the even spacing. On a 300 pt axis a + // 2-up cell may still be dragged down to 80 pt, where capping the gap at 0.5/(n+1) would + // have stopped it at 75. + let narrow = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(300.0, 200.0)); + let mut splits = Vec::new(); + drag_border("twoUpVertical", narrow, 2, &mut splits, 0, egui::pos2(-100.0, 0.0)); + assert!((cells("twoUpVertical", narrow, 2, &splits).unwrap()[0].width() - MIN_CELL).abs() < 0.01); + + // And when the neighbours leave no room for a cell of that width, the border stays put and + // the list keeps its order rather than taking a value from the wrong side of them. + let tight = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(200.0, 200.0)); + let mut splits = vec![0.45, 0.55]; + drag_border("threeUpVertical", tight, 3, &mut splits, 1, egui::pos2(10.0, 0.0)); + assert_eq!(splits, vec![0.45, 0.55], "no room: unchanged"); + let mut splits = vec![0.45, 0.55]; + drag_border("threeUpVertical", tight, 3, &mut splits, 0, egui::pos2(190.0, 0.0)); + assert_eq!(splits, vec![0.45, 0.55], "and from the other side too"); } #[test] From b4a2cdb9e99d6a68f3f14c71b7b6691ae4d6ae5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:44:36 +0000 Subject: [PATCH 078/310] #10101: take the grip flag back out, and cover the active document instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag added in the previous commit cannot be shown to do anything: with it removed, a press on the grip inside the *active* document still drags the border and still paints nothing. egui gives the press to the grip, which is laid out after the canvases, so the canvas never sees it — the reported case does not reproduce. Rather than keep a guard no test can tell apart, it goes, and the comment says why the order is enough. What stays is the test it prompted, which is the one the review asked for: a grab 2 pt inside the active document's edge, with the Brush selected, drags the border, adds no history entry, and `arranged_cells` reports both cells meeting at the new position. Also documents `arranged_cells` and `arrange`, which the docstring check had nothing to read on. --- crates/ui-egui/src/app.rs | 1 - crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/tabs.rs | 11 +++-- crates/ui-egui/src/lib.rs | 3 -- crates/ui-egui/src/menus/view_cmds/arrange.rs | 3 ++ crates/ui-egui/src/menus/view_cmds/tests.rs | 48 +++++++++++++++++++ 6 files changed, 58 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b5101c1fc5..c1660d75f9 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -94,7 +94,6 @@ impl PhotocraftApp { fonts_ready: false, last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), arrange_start: 0, - arrange_grip_press: false, ppp: 1.0, drop_canvas_rect: None, tab_strip: None, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 71cce10d95..3196f4951f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1792,9 +1792,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // never maps through a camera that is still moving under the pointer. let (pointer_here, hover_here) = ctx.input(|i| { ( - // A press that began on an arrangement's border grip belongs to that border, not to - // the canvas it overlaps (#10101 review). - !app.arrange_grip_press && (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| rect.contains(p)), + (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| rect.contains(p)), i.pointer.hover_pos().is_some_and(|p| rect.contains(p)), ) }); diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index e3b2471f43..cab786eb5a 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -15,6 +15,10 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::brush_mode_menu::show(app, ui.ctx()); } +/// Where each docked document is drawn under Window › Arrange: its index and the cell it gets, +/// at the border positions the user has dragged them to. `None` with no docked document, or when +/// the arrangement is the plain single canvas. With more documents than the layout has cells, the +/// shown run starts at [`PhotocraftApp::arrange_start`] and always includes the active one. pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option> { let documents = docked_document_indices(app); let n = documents.len(); @@ -85,12 +89,9 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(shown) = arranged_cells(app, rect) { app.arrange_start = shown.first().map_or(0, |(d, _)| *d); let t = crate::theme::Tokens::get(ui.ctx()); - // A press on a border grip drags the border (`drag_borders`): it activates no document, - // and the canvas under it takes no tool input from it either — a grip overlaps the - // active document's edge, where a press would otherwise start a stroke (#10101 review). + // A press on a border grip drags the border (`drag_borders`); it activates no document. + // The grips are laid out after the canvases, so egui gives them the press (#10101). let grips: Vec = border_grips(app, rect).into_iter().map(|(_, grip)| grip).collect(); - app.arrange_grip_press = - ui.input(|i| (i.pointer.any_pressed() || i.pointer.any_down()) && i.pointer.press_origin().is_some_and(|p| grips.iter().any(|g| g.contains(p)))); for (d, cell) in shown { let cell = cell.shrink(1.0); let view = app.ui.views[d].clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2a8cd5b199..50b0174631 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -465,9 +465,6 @@ pub struct PhotocraftApp { /// Window › Arrange: the first document of the shown run of tiles, when there are more /// documents than tiles (`canvas::arranged_cells`). pub(crate) arrange_start: usize, - /// Window › Arrange: this frame's pointer press began on a border grip, so the canvases take - /// no tool input from it — the press drags the border (`canvas::tabs::drag_borders`, #10101). - pub(crate) arrange_grip_press: bool, /// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`, which /// folds in both the display scale and Interface › UI Scale). The canvas maps document pixels /// to physical pixels, so point-space geometry divides the view zoom by this (see diff --git a/crates/ui-egui/src/menus/view_cmds/arrange.rs b/crates/ui-egui/src/menus/view_cmds/arrange.rs index 04ec1600b8..6b9587f9e1 100644 --- a/crates/ui-egui/src/menus/view_cmds/arrange.rs +++ b/crates/ui-egui/src/menus/view_cmds/arrange.rs @@ -48,6 +48,9 @@ pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize, f wid } +/// The Window › Arrange items: a layout (2-up, 4-up, Tiled…), which starts from even cells and +/// refits every document, or one of the window commands beside them — Consolidate All to Tabs, +/// New Window, Float in Window, Float All in Windows, Match Zoom and the rest. pub(super) fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { if LAYOUTS.contains(&k) { app.ui.view.arrange = k.to_string(); diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index ddf9a11729..813d9c36cd 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -983,3 +983,51 @@ fn full_screen_modes_need_a_document() { leave_full_screen_without_document(&mut app, &ctx); assert_eq!(app.ui.view.screen_mode, "standard"); } + +/// #10101 review: the grip overlaps the *active* document's edge too, and a press there must +/// drag the border without painting. The cells the canvases are actually drawn in follow. +#[test] +fn grabbing_the_border_inside_the_active_document_drags_it_without_painting() { + use egui_kittest::Harness; + let (mut app, _) = app_with(2); + app.ui.view.arrange = "twoUpVertical".into(); + app.ui.tool = crate::state::Tool::Brush; + // The right-hand document is active, and the grab below is inside *its* edge. + app.session.set_active(1); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let area = h.state().last_canvas_rect; + let n = h.state().session.documents().len(); + let border = borders("twoUpVertical", area, n, &[])[0].line; + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().map_or(0, |d| d.history.entries().len()); + let before = steps(&h); + // 2 pt into the right-hand (active) document, where its canvas would otherwise take the press. + let from = border.center() + egui::vec2(2.0, 0.0); + let to = from + egui::vec2(150.0, 0.0); + let ev = |h: &mut Harness<'_, PhotocraftApp>, e: egui::Event| h.input_mut().events.push(e); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + ev(&mut h, egui::Event::PointerMoved(from)); + h.run(); + ev(&mut h, button(from, true)); + h.run(); + for step in 1..=10 { + ev(&mut h, egui::Event::PointerMoved(from + (to - from) * (step as f32 / 10.0))); + h.run(); + } + ev(&mut h, button(to, false)); + h.run_steps(2); + assert_eq!(steps(&h), before, "the press dragged the border and painted nothing"); + // And the cells the documents are drawn in follow the border, not just the stored fractions. + let shown = crate::canvas::arranged_cells(h.state(), area).expect("two documents are arranged"); + assert!((shown[0].1.max.x - to.x).abs() < 2.0, "the left cell ends at the border: {:?}, pointer at {}", shown[0].1, to.x); + assert!((shown[1].1.min.x - to.x).abs() < 2.0, "and the right one starts there: {:?}", shown[1].1); +} From 447f3d7afc50633a245911ce0839797a034ea7b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 08:40:36 -0800 Subject: [PATCH 079/310] Adjustments: Equalize's choices stay with their document, and a string selectedAreaOnly is not exported Saving an action whose Equalize step has `selectedAreaOnly: "true"` wrote a bare Equalize event, although the command refuses the string. Export now accepts only the boolean `true` for that setting. The Equalize dialog records the document it was opened for. OK while another document is active leaves the dialog open and runs nothing. --- crates/engine/src/cmd/atn_cmds/events.rs | 8 ++++++-- crates/engine/src/cmd/atn_cmds/tests.rs | 2 ++ crates/ui-egui/src/dialogs.rs | 5 ++++- crates/ui-egui/src/dialogs/equalize_ui.rs | 11 +++++++++++ .../ui-egui/src/dialogs/equalize_ui/tests.rs | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 04b8d6869c..1e7736bfbb 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -78,11 +78,14 @@ fn applies_at_canvas_bounds(p: &Value) -> bool { /// /// A step that leaves the setting at the value the event plays as exports as before. /// How a setting the event cannot carry behaves once the step is replayed without it. Which of -/// the two a setting takes follows the command's own reading of it, not the parameter spec. +/// these a setting takes follows the command's own reading of it, not the parameter spec. enum Lost { /// Replay uses this value, so only a step already at it survives. Numbers, strings and /// booleans are compared as written, since the commands below read these settings as written. ReplaysAs(&'static str), + /// Replay uses this boolean, and the command refuses any other type, so only a step at this + /// boolean survives: a string such as `"true"` is refused. + ReplaysAsBool(bool), /// The command reads only this one spelling as anything other than what replay does, so every /// other value survives -- a differently cased one included, because it too plays as replay's. DiffersAt(&'static str), @@ -98,7 +101,7 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[ ("filter.other.minimum", &[("preserve", Lost::DiffersAt("roundness"))]), ("filter.other.maximum", &[("preserve", Lost::DiffersAt("roundness"))]), // Eqlz is exported with no fields; replay equalizes the selected area only. - ("image.adjustments.equalize", &[("selectedAreaOnly", Lost::ReplaysAs("true"))]), + ("image.adjustments.equalize", &[("selectedAreaOnly", Lost::ReplaysAsBool(true))]), ]; /// Whether every setting of `p` that its event cannot hold would play the same without it @@ -112,6 +115,7 @@ fn holds_its_settings(id: &str, p: &Value) -> bool { || v.as_f64().is_some_and(|n| played.parse::().is_ok_and(|d| n == d)) || v.as_bool().is_some_and(|b| played.parse::() == Ok(b)) } + (Some(v), Lost::ReplaysAsBool(played)) => v.as_bool() == Some(*played), (Some(v), Lost::DiffersAt(spelling)) => v.as_str() != Some(spelling), }) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index c70bd048ed..658febc5ee 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -441,6 +441,8 @@ fn a_setting_the_event_cannot_hold_is_refused() { ("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})), ("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})), ("image.adjustments.equalize", json!({"selectedAreaOnly": false})), + // Equalize reads `selectedAreaOnly` as a boolean only and refuses the string. + ("image.adjustments.equalize", json!({"selectedAreaOnly": "true"})), ("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})), ("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})), ] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8d52dbc7ce..dd811a7381 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -963,7 +963,7 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Result<(), String> { if crate::layer_style::color_picker::has_child(app, id) { return Err("confirm or cancel the Layer Style color picker first".into()); @@ -983,6 +983,9 @@ fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { } crate::guide_layout_ui::validate(app, &d.fields)?; } + if let Some(d) = app.ui.dialogs.iter().find(|d| d.id == id).filter(|d| crate::equalize_ui::owns(&d.fields)) { + crate::equalize_ui::validate(app, &d.fields)?; + } Ok(()) } diff --git a/crates/ui-egui/src/dialogs/equalize_ui.rs b/crates/ui-egui/src/dialogs/equalize_ui.rs index 42aafeb12a..f1fe333476 100644 --- a/crates/ui-egui/src/dialogs/equalize_ui.rs +++ b/crates/ui-egui/src/dialogs/equalize_ui.rs @@ -30,10 +30,21 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { f.insert(MARK.into(), json!(true)); f.insert("__command".into(), json!(COMMAND)); f.insert("__label".into(), json!("Equalize")); + f.insert("__document".into(), app.session.active().map(|s| json!(s.doc.id)).unwrap_or(Value::Null)); f.insert(KEY.into(), json!(true)); app.ui.open_dialog(DialogKind::Command, f) } +/// OK runs only while the document the dialog was opened for is active; otherwise the dialog +/// stays open. +pub fn validate(app: &PhotocraftApp, f: &Map) -> Result<(), String> { + let st = app.session.active().ok_or("no document")?; + if f.get("__document") != Some(&json!(st.doc.id)) { + return Err("the Equalize dialog belongs to another document".into()); + } + Ok(()) +} + pub fn body(ui: &mut egui::Ui, f: &mut Map) { let mut only = f.get(KEY).and_then(Value::as_bool).unwrap_or(true); ui.radio_value(&mut only, true, tl!("Equalize Selected Area Only")); diff --git a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs index 58fcdf3774..645aa6fb25 100644 --- a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs @@ -67,6 +67,24 @@ fn entire_image_based_on_the_selected_area_maps_the_whole_layer() { assert!(gray(&app, 30, 1) < 0.05 && gray(&app, 30, 20) > 0.95, "outside the selection too"); } +/// OK on a dialog opened for one document, while another document is active, equalizes nothing and +/// leaves the dialog open. +#[test] +fn ok_refuses_while_another_document_is_active() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.run("file.new", json!({"width": 40, "height": 30})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, d).is_err()); + assert!(app.ui.dialogs.iter().any(|x| x.id == d), "the dialog stays open"); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "the other document is unchanged"); + assert!(app.session.set_active(0)); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01, "nothing ran on the dialog's document either"); + crate::dialogs::confirm(&mut app, d).unwrap(); + assert!(gray(&app, 1, 1) < 0.05, "OK runs once its document is active again"); +} + #[test] fn cancel_leaves_the_layer_unchanged() { let (mut app, ctx) = setup(true); From dd1f34a57df5eb8d32ff8dc47b5f65e71ddebd7a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 11:07:35 -0800 Subject: [PATCH 080/310] 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 b71e3c8030..8eea5bb0c0 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 71eae66ddd..19614c52e1 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 0000000000..305055fb44 --- /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 e979d3d829..ca47bb72b3 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 bddc6796d3..0cb7868d28 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 52149ab8e0..736fe41dad 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 22883f8ba7..acd105eb74 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 666f6e101f..d0388ee437 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 c79f12df3c..da16d1054a 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 316b9ebe24..ed5c5c1f29 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 a29feb358f..9b7da8d674 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 38b681c65d..acde6baeb0 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 d87c92323d..e811e64efb 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 dedda70ab5fafb8f484c10363dc22c7269e80346 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:40:33 +0000 Subject: [PATCH 081/310] #10726: carry on the selected subpath, from the path the Pen draws on, and keep a smooth endpoint smooth Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 69 ++++++++++++++ crates/ui-egui/src/tools/direct_select.rs | 8 +- .../ui-egui/src/tools/vector_ui/curvature.rs | 27 +++++- crates/ui-egui/src/tools/vector_ui/pen.rs | 94 +++++++++++++------ 4 files changed, 161 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index d4d4910eab..72d4ff0736 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -258,6 +258,75 @@ fn the_curvature_pen_carries_on_an_open_path() { assert_eq!(app.ui.pen.as_ref().map(|p| p.knots[1][0]), Some([80.0, 20.0])); } +/// The work path set to `subpaths`, in one step. +fn set_work_path(app: &mut PhotocraftApp, subpaths: Vec) { + app.session + .edit("path", |doc, _| { + doc.work_path = Some(Path::new(subpaths)); + Ok(()) + }) + .unwrap(); +} + +/// Direct Selection's selection: anchor `[subpath, knot]` of `target`, alone. +fn select_anchor(app: &mut PhotocraftApp, target: crate::direct_select::PathRef, anchor: [usize; 2]) { + app.ui.direct_selection.target = Some(target); + app.ui.direct_selection.anchors = vec![anchor]; +} + +/// CodeRabbit on #10744: two open subpaths end on the same point, and the Curvature Pen carries on +/// the one whose endpoint is selected, not the first one ending there. +#[test] +fn the_curvature_pen_carries_on_the_selected_subpath_of_two_that_end_together() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 80.0)]), Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [1, 1]); + click(&mut app, 140.0, 150.0); + assert_eq!(app.ui.pen.as_ref().and_then(|p| p.resumes.as_ref()).map(|r| r.index), Some(1), "the selected subpath"); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.iter().map(|s| s.knots.len()).collect::>(), [2, 3], "the second subpath grew"); +} + +/// CodeRabbit on #10744: a smooth endpoint carried on keeps its incoming handle, and its outgoing +/// one, bent into the new segment, stays in line with it: the knot stays smooth. +#[test] +fn a_smooth_endpoint_carried_on_stays_smooth() { + use photocraft_doc::vector::{Knot, Point, Subpath}; + let mut app = app(Tool::CurvaturePen); + let end = Knot::smooth(Point::new(80.0, 80.0), Point::new(60.0, 60.0), Point::new(100.0, 100.0)); + set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![Knot::corner(20.0, 20.0), end], op: Default::default() }]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 1]); + click(&mut app, 140.0, 40.0); + let knots = app.ui.pen.as_ref().map(|p| p.knots.clone()).expect("the path is carried on"); + let [anchor, in_ctrl, out_ctrl] = knots[1]; + assert_eq!(in_ctrl, [60.0, 60.0], "the incoming handle is kept"); + let (a, b) = ([anchor[0] - in_ctrl[0], anchor[1] - in_ctrl[1]], [out_ctrl[0] - anchor[0], out_ctrl[1] - anchor[1]]); + assert!(b[0].hypot(b[1]) > 1.0, "the outgoing handle bends into the new segment: {out_ctrl:?}"); + assert!((a[0] * b[1] - a[1] * b[0]).abs() < 1e-6 && a[0] * b[0] + a[1] * b[1] > 0.0, "in line: {knots:?}"); +} + +/// CodeRabbit on #10744: in Path mode, an endpoint selected on a shape layer is not one a Pen path +/// carries on, even with the work path ending on the same point: the press starts a new path. +#[test] +fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::Pen); + app.ui.tool_options.vector_mode = "shape".into(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let shape = app.session.active().and_then(|st| st.active_layer).expect("a shape layer").0; + app.ui.tool_options.vector_mode = "path".into(); + set_work_path(&mut app, vec![Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); + app.ui.tool = Tool::CurvaturePen; + click(&mut app, 140.0, 150.0); + let pen = app.ui.pen.as_ref().expect("a new path"); + assert!(pen.resumes.is_none() && pen.knots.len() == 1, "{pen:?}"); +} + /// #6593 review: carrying on a subpath keeps its path operation, and a draft whose subpath changed /// meanwhile refuses without overwriting newer work. The historical test name is retained /// for reviewed-head provenance; captured-revision validation supersedes its stale fallback. diff --git a/crates/ui-egui/src/tools/direct_select.rs b/crates/ui-egui/src/tools/direct_select.rs index 231307e5ac..ba8ab3b8ac 100644 --- a/crates/ui-egui/src/tools/direct_select.rs +++ b/crates/ui-egui/src/tools/direct_select.rs @@ -243,14 +243,14 @@ fn converting(tool: Tool, mods: Modifiers, pen_idle: bool) -> bool { /// The selected anchor when it is the only one and an endpoint of an open subpath of the active /// path: where the Curvature Pen carries the path on from (#10726), as Photoshop does after a -/// click, or ⌘/Ctrl-click, on the last point. -pub(crate) fn selected_open_end(app: &PhotocraftApp) -> Option<[f64; 2]> { +/// click, or ⌘/Ctrl-click, on the last point. The path, the subpath's index and the knot's. +pub(crate) fn selected_open_end(app: &PhotocraftApp) -> Option<(PathRef, usize, usize)> { let ds = &app.ui.direct_selection; let ([[s, k]], Some(target)) = (ds.anchors.as_slice(), ds.target) else { return None }; let (_, path) = active_paths(app).into_iter().find(|(r, _)| *r == target)?; let sub = path.subpaths.get(*s).filter(|sub| !sub.closed)?; - let knot = sub.knots.get(*k).filter(|_| *k == 0 || *k + 1 == sub.knots.len())?; - Some([knot.anchor.x, knot.anchor.y]) + sub.knots.get(*k).filter(|_| *k == 0 || *k + 1 == sub.knots.len())?; + Some((target, *s, *k)) } /// Is the Curvature Pen idle (no path being drawn, no ⌘/Ctrl)? Then it edits the active path in diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index d09dc6947a..86951ab4b2 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -1,10 +1,11 @@ -use super::pen::{cancel_stale, pen_commit, resume_at}; +use super::pen::{cancel_stale, pen_commit, resume_selected}; use super::{PenPath, PhotocraftApp}; /// Recompute a Curvature Pen path's handles from its anchors and corner points. The knots of a /// path it carries on (`PenPath::fixed`) keep theirs, but for the endpoint's outgoing handle, /// which bends into the first new segment (#10726), and, once the path closes, the first knot's -/// incoming handle, which bends into the closing one. +/// incoming handle, which bends into the closing one. A smooth knot's bent handle stays in line +/// with the one it keeps, so the knot stays smooth. pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); let corners: Vec = (0..anchors.len()).map(|i| pen.unlinked.contains(&i)).collect(); @@ -14,6 +15,7 @@ pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { for (i, was) in pen.fixed.iter().enumerate() { let Some(k) = pen.knots.get_mut(i) else { break }; let (keep_in, keep_out) = (!(closed && i == 0), !(extended && i == last)); + let smooth = !pen.unlinked.contains(&i); k[0] = was[0]; if keep_in { k[1] = was[1]; @@ -21,9 +23,27 @@ pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { if keep_out { k[2] = was[2]; } + if smooth && keep_in != keep_out { + let (kept, bent) = if keep_in { (1, 2) } else { (2, 1) }; + if let Some(h) = in_line(k[0], k[kept], k[bent]) { + k[bent] = h; + } + } } } +/// A handle as long as `bent` from `anchor`, on the far side of it from `kept`: `None` when +/// `kept` lies on the anchor and gives no direction. +fn in_line(anchor: [f64; 2], kept: [f64; 2], bent: [f64; 2]) -> Option<[f64; 2]> { + let (dx, dy) = (anchor[0] - kept[0], anchor[1] - kept[1]); + let d = dx.hypot(dy); + if !(d > 1e-9 && d.is_finite()) { + return None; + } + let len = (bent[0] - anchor[0]).hypot(bent[1] - anchor[1]); + len.is_finite().then(|| [anchor[0] + dx / d * len, anchor[1] + dy / d * len]) +} + /// The anchor of the Curvature Pen path being drawn under (x, y), for the cursor. pub(crate) fn curvature_anchor_at(app: &PhotocraftApp, x: f64, y: f64) -> Option { curvature_hit(app, x, y) @@ -64,8 +84,7 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { // carries that path on from there, as in Photoshop (#10726): its segments keep their shape, // the press adds the next anchor and the commit extends that subpath. if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) - && let Some(end) = crate::direct_select::selected_open_end(app) - && let Some(mut resumed) = resume_at(app, end[0], end[1], 0.5) + && let Some(mut resumed) = resume_selected(app) { resumed.curvature = true; resumed.fixed = resumed.knots.clone(); diff --git a/crates/ui-egui/src/tools/vector_ui/pen.rs b/crates/ui-egui/src/tools/vector_ui/pen.rs index bf5a742b86..42936af66e 100644 --- a/crates/ui-egui/src/tools/vector_ui/pen.rs +++ b/crates/ui-egui/src/tools/vector_ui/pen.rs @@ -474,28 +474,47 @@ 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 that has its first or last anchor at `(x, y)`, as a Pen path to carry on -/// drawing from there (#6593): of a targeted vector mask, else in Shape mode of the selected shape -/// layer, else of the path a new Pen path would join (the one selected in the Paths panel, else -/// the work path). Its knots run towards that anchor: reversed, handles swapped, when it is the -/// first. -pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { +/// The path a Pen path carries on (#6593): a targeted vector mask, else in Shape mode the +/// selected shape layer's, else the path a new Pen path would join (the one selected in the Paths +/// panel, else the work path). Its Paths-panel name ("layer" for a layer's), layer and path. +fn resume_target(app: &PhotocraftApp) -> Option<(String, Option, Path)> { let st = app.session.active()?; - let doc = st.doc.id.0; - let (name, layer, path) = if let Some((id, p)) = targeted_vector_mask(app) { - ("layer".to_string(), Some(id), p) - } else if app.ui.tool_options.vector_mode == "shape" { + if let Some((id, p)) = targeted_vector_mask(app) { + return Some(("layer".to_string(), Some(id), p)); + } + if app.ui.tool_options.vector_mode == "shape" { let (id, p) = active_shape_path(app)?; - ("layer".to_string(), Some(id), p) - } else { - let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); - // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). - if name == "work" && !work_path_shown(app) { - return None; + return Some(("layer".to_string(), Some(id), p)); + } + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + // A Work Path hidden by Make Selection isn't carried on: a new path replaces it (#6079). + if name == "work" && !work_path_shown(app) { + return None; + } + let p = doc_path(&st.doc, &name)?.clone(); + Some((name, None, p)) +} + +/// Subpath `index` of the path a Pen path carries on, as a Pen path to carry on drawing from its +/// last anchor, or from its first when `reversed`: then its knots run backwards, handles swapped. +fn resumed(doc: u64, name: String, layer: Option, index: usize, sub: &photocraft_doc::vector::Subpath, reversed: bool) -> PenPath { + let mut knots: Vec<_> = sub.knots.iter().map(|k| (knot_points(k), k.smooth)).collect(); + if reversed { + knots.reverse(); + for (k, _) in &mut knots { + k.swap(1, 2); } - let p = doc_path(&st.doc, &name)?.clone(); - (name, None, p) - }; + } + let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); + let resumes = Resumed { doc, path: name, layer, index, was: sub.clone() }; + PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() } +} + +/// The open subpath that has its first or last anchor at `(x, y)` in the path a Pen path carries +/// on ([`resume_target`]), as a Pen path to carry on drawing from there (#6593). +pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { + let doc = app.session.active()?.doc.id.0; + let (name, layer, path) = resume_target(app)?; let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; path.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { let reversed = if s.knots.last().is_some_and(near) { @@ -505,19 +524,36 @@ pub(super) fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option } else { return None; }; - let mut knots: Vec<_> = s.knots.iter().map(|k| (knot_points(k), k.smooth)).collect(); - if reversed { - knots.reverse(); - for (k, _) in &mut knots { - k.swap(1, 2); - } - } - let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); - let resumes = Resumed { doc, path: name.clone(), layer, index: i, was: s.clone() }; - Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) + Some(resumed(doc, name.clone(), layer, i, s, reversed)) }) } +/// The endpoint Direct Selection has selected ([`crate::direct_select::selected_open_end`]), as +/// a Pen path carrying its very subpath on from it (#10726). `None` unless that endpoint is in +/// the path a Pen path carries on ([`resume_target`]): one in another path starts a new one. +pub(super) fn resume_selected(app: &PhotocraftApp) -> Option { + let (target, index, knot) = crate::direct_select::selected_open_end(app)?; + let st = app.session.active()?; + let (name, layer, path) = resume_target(app)?; + let carried = match layer { + Some(id) => crate::direct_select::PathRef::Layer(id), + None if name == "work" => crate::direct_select::PathRef::Work, + None => crate::direct_select::PathRef::Saved(st.doc.paths.iter().position(|p| p.name == name)?), + }; + if carried != target { + return None; + } + let sub = path.subpaths.get(index).filter(|s| !s.closed)?; + let reversed = if knot + 1 == sub.knots.len() { + false + } else if knot == 0 { + true + } else { + return None; + }; + Some(resumed(st.doc.id.0, name, layer, index, sub, reversed)) +} + /// The resumed path with its subpath carried on: the one subpath of `drawn` takes the old one's /// 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). From 9aa1000071cd1e671b6bea4597515197ab5ed0e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:40:36 +0000 Subject: [PATCH 082/310] #10101: the active document stays active when its edge's grip is dragged Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/menus/view_cmds/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/menus/view_cmds/tests.rs b/crates/ui-egui/src/menus/view_cmds/tests.rs index 813d9c36cd..c1e706bc2e 100644 --- a/crates/ui-egui/src/menus/view_cmds/tests.rs +++ b/crates/ui-egui/src/menus/view_cmds/tests.rs @@ -1026,6 +1026,7 @@ fn grabbing_the_border_inside_the_active_document_drags_it_without_painting() { ev(&mut h, button(to, false)); h.run_steps(2); assert_eq!(steps(&h), before, "the press dragged the border and painted nothing"); + assert_eq!(h.state().session.active_index(), Some(1), "the active document stays active"); // And the cells the documents are drawn in follow the border, not just the stored fractions. let shown = crate::canvas::arranged_cells(h.state(), area).expect("two documents are arranged"); assert!((shown[0].1.max.x - to.x).abs() < 2.0, "the left cell ends at the border: {:?}, pointer at {}", shown[0].1, to.x); From a386a31aa927ef85bef6a1785da374d7fb2fe67a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:49:17 +0000 Subject: [PATCH 083/310] #10726: import Point from geom in the test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 72d4ff0736..a51958f2b8 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -293,7 +293,8 @@ fn the_curvature_pen_carries_on_the_selected_subpath_of_two_that_end_together() /// one, bent into the new segment, stays in line with it: the knot stays smooth. #[test] fn a_smooth_endpoint_carried_on_stays_smooth() { - use photocraft_doc::vector::{Knot, Point, Subpath}; + use photocraft_doc::vector::{Knot, Subpath}; + use photocraft_geom::Point; let mut app = app(Tool::CurvaturePen); let end = Knot::smooth(Point::new(80.0, 80.0), Point::new(60.0, 60.0), Point::new(100.0, 100.0)); set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![Knot::corner(20.0, 20.0), end], op: Default::default() }]); From f81c02851492fce1d2c55aef4a3c7ef7c558f108 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 12:19:52 -0800 Subject: [PATCH 084/310] 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 0176e0fe84..9458afacf3 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 0802a02ebf..75ff86cb8a 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 0000000000..ad183cbc6c --- /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 024888c65d..c320b67f1a 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 675996c5d0..96a5272524 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 8eea5bb0c0..cafdfb8176 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 305055fb44..d17b32e20e 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 e7351ef476..83ff29f105 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 e810d0086d..d052e5f3a5 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 e78563b83e..cdbd800633 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 0cb7868d28..e93686642c 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 736fe41dad..418f899ac7 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 acd105eb74..f815a0f0c4 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 da16d1054a..23dd31a062 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 81924b492e..1b3148eecd 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 9b7da8d674..d3d3479fcf 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 acde6baeb0..0d08f0fd2f 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 c2b044bb695ef9e423c96499e0120fefad572c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:45:04 +0000 Subject: [PATCH 085/310] #10726: the tests' selection is made on the current document Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index a51958f2b8..36c05e0ae2 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -272,6 +272,8 @@ fn set_work_path(app: &mut PhotocraftApp, subpaths: Vec Date: Mon, 2 Nov 2026 21:44:45 +0000 Subject: [PATCH 086/310] #10726: the target test selects the work path while the Pen carries on the shape's Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 36c05e0ae2..011e58a7fe 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -310,8 +310,9 @@ fn a_smooth_endpoint_carried_on_stays_smooth() { assert!((a[0] * b[1] - a[1] * b[0]).abs() < 1e-6 && a[0] * b[0] + a[1] * b[1] > 0.0, "in line: {knots:?}"); } -/// CodeRabbit on #10744: in Path mode, an endpoint selected on a shape layer is not one a Pen path -/// carries on, even with the work path ending on the same point: the press starts a new path. +/// CodeRabbit on #10744: in Shape mode the Pen carries on the selected shape layer's path, so an +/// endpoint selected on the work path, even one on the same point as the shape's, is not carried +/// on: the press starts a new path. #[test] fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { use photocraft_doc::vector::Subpath; @@ -320,10 +321,9 @@ fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { click(&mut app, 20.0, 20.0); click(&mut app, 80.0, 80.0); crate::vector_ui::pen_commit(&mut app, false); - let shape = app.session.active().and_then(|st| st.active_layer).expect("a shape layer").0; - app.ui.tool_options.vector_mode = "path".into(); + assert!(app.session.active().and_then(|st| st.active_layer).is_some(), "a shape layer"); set_work_path(&mut app, vec![Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); - select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 1]); app.ui.tool = Tool::CurvaturePen; click(&mut app, 140.0, 150.0); let pen = app.ui.pen.as_ref().expect("a new path"); From 503908fa104d826701aa8a5c35c7598dc0d6af1b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 14:11:25 -0800 Subject: [PATCH 087/310] 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 4f65bcc34e..33b333d423 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 3bdfdea33a9a37e0de06579ec7cd5ef517534105 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:15:14 +0000 Subject: [PATCH 088/310] #10726: the target test leaves the Paths panel on no path, so the Pen draws on the work path Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 011e58a7fe..0ac2d332a2 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -310,9 +310,9 @@ fn a_smooth_endpoint_carried_on_stays_smooth() { assert!((a[0] * b[1] - a[1] * b[0]).abs() < 1e-6 && a[0] * b[0] + a[1] * b[1] > 0.0, "in line: {knots:?}"); } -/// CodeRabbit on #10744: in Shape mode the Pen carries on the selected shape layer's path, so an -/// endpoint selected on the work path, even one on the same point as the shape's, is not carried -/// on: the press starts a new path. +/// CodeRabbit on #10744: in Path mode the Pen carries on the work path, so an endpoint selected +/// on a shape layer, even one on the same point as the work path's, is not carried on: the +/// press starts a new path rather than carrying on the work path from there. #[test] fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { use photocraft_doc::vector::Subpath; @@ -321,9 +321,12 @@ fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { click(&mut app, 20.0, 20.0); click(&mut app, 80.0, 80.0); crate::vector_ui::pen_commit(&mut app, false); - assert!(app.session.active().and_then(|st| st.active_layer).is_some(), "a shape layer"); + let shape = app.session.active().and_then(|st| st.active_layer).expect("a shape layer").0; + app.ui.tool_options.vector_mode = "path".into(); + // The Paths panel on no path: the Pen draws on the work path, not the layer's. + app.ui.selected_path = None; set_work_path(&mut app, vec![Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); - select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 1]); + select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); app.ui.tool = Tool::CurvaturePen; click(&mut app, 140.0, 150.0); let pen = app.ui.pen.as_ref().expect("a new path"); From 2f801162caa9d55d58ed7f55e6d4b4f03132e14b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Mon, 2 Nov 2026 14:34:30 -0800 Subject: [PATCH 089/310] 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 976d23b833..6a55b211b1 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 e35999dedc..0baadd34fc 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 090/310] 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 3700512037..ec7fdf3d62 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 d982cec997..b32da02ccf 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 215fab4a67..9825e54988 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 ad8355ef34..cbbbdad015 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 33b333d423..429765001f 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 6b6193b4c6..971d8b78ea 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 e811e64efb..3a601e7327 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 091/310] 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 ad183cbc6c..64479576f6 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 c320b67f1a..a28cbddc60 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 83ff29f105..a8cc332a59 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 d052e5f3a5..d023966f7f 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 cdbd800633..80eb14502d 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 e93686642c..3351a56cb9 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 418f899ac7..02076f8497 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 6a55b211b1..01f8a0a5d0 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 0baadd34fc..c2415dec70 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 f48d539c25..d783fa0429 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 092/310] 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 05b6f36691..025a9ea2e7 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 093/310] 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 8ba9e42544..6fca427048 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 01f8a0a5d0..0630fa9c5d 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 23dd31a062..09449c7fe1 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 0d08f0fd2f..298b95296b 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 3a601e7327..e54fba9a7c 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 99f883fc98..5a6601ff90 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 376af9145f31b6f0c9ac9a4be853953fab81aa84 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Tue, 3 Nov 2026 07:51:23 +0400 Subject: [PATCH 094/310] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 112 ++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b4a040255b..2e6fec24fb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1232,7 +1232,6 @@ No character style სიმბოლოს სტილის გარეშე No document დოკუმენტი არ არის No matching commands შესაბამისი ბრძანება ვერ მოიძებნა - No matching fonts შესაბამისი შრიფტები არ არის No presets for the current tool. მიმდინარე ხელსაწყოს პრესეტები არ არის. No presets of this type. ამ ტიპის პრესეტები არ არის. No properties თვისებები არ არის @@ -4552,53 +4551,90 @@ layerLabel Magenta მეწამული Font size presets შრიფტის ზომის პრესეტები Leading presets სტრიქონთა დაშორების პრესეტები Tracking presets ტრეკინგის პრესეტები -# Added with origin/main (2026-10-31): Select and Mask, histogram, pen, window menu +# Select and Mask workspace + Color Aware ფერის გათვალისწინებით + Decontaminate Colors ფერების გაწმენდა + Edge Detection კიდის აღმოჩენა + Global Refinements გლობალური დაზუსტებები + High Quality Preview მაღალი ხარისხის წინასწარი ნახვა + Object Aware ობიექტის გათვალისწინებით + Real-Time Refinement დაზუსტება რეალურ დროში + Refine Edge Brush Tool კიდის დაზუსტების ფუნჯის ხელსაწყო + Refine Hair თმის დაზუსტება + Refine Mode დაზუსტების რეჟიმი + Remember Settings პარამეტრების დამახსოვრება + View Mode ხედის რეჟიმი + Object Aware needs a segmentation model, which isn't available yet „ობიექტის გათვალისწინებით“ რეჟიმს სჭირდება სეგმენტაციის მოდელი, რომელიც ჯერ მიუწვდომელია + Refine Hair needs hair detection, which isn't available yet „თმის დაზუსტებას“ სჭირდება თმის აღმოჩენა, რომელიც ჯერ მიუწვდომელია + Select and Mask was closed: its document is no longer the active one „მონიშვნა და ნიღაბი“ დაიხურა: მისი დოკუმენტი აღარ არის აქტიური + Select and Mask was closed: another layer was selected „მონიშვნა და ნიღაბი“ დაიხურა: მონიშნულია სხვა შრე + Path operations ტრაექტორიის ოპერაციები + Drag to move the selected path გადაათრიეთ მონიშნული ტრაექტორიის გადასაადგილებლად + Move effect up ეფექტის აწევა + Move effect down ეფექტის ჩამოწევა + Delete effect ეფექტის წაშლა + Current Layer: მიმდინარე შრე: + The path was kept as the Work Path ტრაექტორია შენარჩუნდა სამუშაო ტრაექტორიად + The path was discarded ტრაექტორია გაუქმდა + Select Layer Under Cursor კურსორის ქვეშ არსებული შრის მონიშვნა + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დაწკაპეთ მარჯვენა ღილაკით. CPU Rendering CPU-რენდერინგი Calculating… გამოითვლება… Click to refresh histogram with uncached data დაწკაპეთ ჰისტოგრამის ქეშის გარეშე მონაცემებით განსაახლებლად - Color Aware ფერის გათვალისწინებით - Convert to Shape… ფიგურად გარდაქმნა… Count: რაოდენობა: - Current Layer: მიმდინარე შრე: - Decontaminate Colors ფერების გაწმენდა - Delete Hidden Layers დამალული შრეების წაშლა - Delete effect ეფექტის წაშლა - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. გადაათრიეთ გამოსახულებაზე შეზღუდვის დასამატებლად; Shift — ჰორიზონტალური/ვერტიკალური; წასაშლელად დაწკაპეთ მარჯვენა ღილაკით. - Drag to move the selected path გადაათრიეთ მონიშნული ტრაექტორიის გადასაადგილებლად Drawn on the CPU: {reason}. Click for System Info. დახატულია CPU-ზე: {reason}. დაწკაპეთ სისტემის ინფორმაციის სანახავად. - Edge Detection კიდის აღმოჩენა - Fit Layer on Screen შრის ეკრანში ჩატევა - Flow: ნაკადი: - Global Refinements გლობალური დაზუსტებები - High Quality Preview მაღალი ხარისხის წინასწარი ნახვა Level: დონე: - Move effect down ეფექტის ჩამოწევა - Move effect up ეფექტის აწევა - Object Aware ობიექტის გათვალისწინებით - Object Aware needs a segmentation model, which isn't available yet „ობიექტის გათვალისწინებით“ რეჟიმს სჭირდება სეგმენტაციის მოდელი, რომელიც ჯერ მიუწვდომელია - Path operations ტრაექტორიის ოპერაციები Percentile: პროცენტილი: - Real-Time Refinement დაზუსტება რეალურ დროში - Refine Edge Brush Tool კიდის დაზუსტების ფუნჯის ხელსაწყო - Refine Edge… კიდის დაზუსტება… - Refine Hair თმის დაზუსტება - Refine Hair needs hair detection, which isn't available yet „თმის დაზუსტებას“ სჭირდება თმის აღმოჩენა, რომელიც ჯერ მიუწვდომელია - Refine Mode დაზუსტების რეჟიმი Release Notes გამოშვების შენიშვნები - Remember Settings პარამეტრების დამახსოვრება - Reset Essentials ძირითადის საწყისზე დაბრუნება - Rounded Rectangle Tool მომრგვალებული მართკუთხედის ხელსაწყო - Select and Mask was closed: another layer was selected „მონიშვნა და ნიღაბი“ დაიხურა: მონიშნულია სხვა შრე - Select and Mask was closed: its document is no longer the active one „მონიშვნა და ნიღაბი“ დაიხურა: მისი დოკუმენტი აღარ არის აქტიური Show Channels in Color არხების ფერად ჩვენება Show Statistics სტატისტიკის ჩვენება Source: წყარო: - Status Bar სტატუსის ზოლი - The path was discarded ტრაექტორია გაუქმდა - The path was kept as the Work Path ტრაექტორია შენარჩუნდა სამუშაო ტრაექტორიად - Tile Horizontally ჰორიზონტალურად განლაგება - Tile Vertically ვერტიკალურად განლაგება Uncached Refresh განახლება ქეშის გარეშე Update and Restart განახლება და გადატვირთვა - View Mode ხედის რეჟიმი - Select Layer Under Cursor კურსორის ქვეშ არსებული შრის მონიშვნა + Convert to Shape… ფიგურად გარდაქმნა… + Delete Hidden Layers დამალული შრეების წაშლა + Fit Layer on Screen შრის ეკრანში ჩატევა + Flow: ნაკადი: + Refine Edge… კიდის დაზუსტება… + Reset Essentials ძირითადის საწყისზე დაბრუნება + Rounded Rectangle Tool მომრგვალებული მართკუთხედის ხელსაწყო + Status Bar სტატუსის ზოლი + Tile Horizontally ჰორიზონტალურად განლაგება + Tile Vertically ვერტიკალურად განლაგება + Edit Brush… ფუნჯის რედაქტირება… +@plural {n} document|{n} documents {n} დოკუმენტი +@plural {n} pixel|{n} pixels {n} პიქსელი +cameraRaw Before (Y) მანამდე (Y) +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw ფილტრი ({layer}) +cameraRaw Camera Raw isn't open Camera Raw არ არის გახსნილი +cameraRaw Cancel გაუქმება +cameraRaw Cancelled opening {name} {name}-ის გახსნა გაუქმდა +cameraRaw Cancelled {label} გაუქმდა: {label} +cameraRaw Color Priority ფერის პრიორიტეტი +cameraRaw Developing {name} მუშავდება {name} +cameraRaw Full-resolution preview unavailable სრული გარჩევადობის გადახედვა მიუწვდომელია +cameraRaw Grain მარცვლოვნება +cameraRaw Highlight Clipping Warning (O) ღია ტონების მოკვეთის გაფრთხილება (O) +cameraRaw Highlight Priority ღია ტონების პრიორიტეტი +cameraRaw Load Settings… პარამეტრების ჩატვირთვა… +cameraRaw Load settings from a Camera Raw .xmp file პარამეტრების ჩატვირთვა Camera Raw-ის .xmp ფაილიდან +cameraRaw Loaded Camera Raw settings from {file} Camera Raw-ის პარამეტრები ჩაიტვირთა {file}-დან +cameraRaw Noise Reduction ხმაურის შემცირება +cameraRaw OK OK +cameraRaw Open გახსნა +cameraRaw Opened {name} გაიხსნა {name} +cameraRaw Paint Overlay საღებავით გადაფარვა +cameraRaw Save Settings… პარამეტრების შენახვა… +cameraRaw Save these settings as a Camera Raw .xmp file ამ პარამეტრების შენახვა Camera Raw-ის .xmp ფაილად +cameraRaw Shadow Clipping Warning (U) ჩრდილების მოკვეთის გაფრთხილება (U) +cameraRaw Sharpening გამკვეთრება +cameraRaw Show Lab Color Readouts Lab ფერის მნიშვნელობების ჩვენება +cameraRaw Show Red at 3 o'clock წითლის ჩვენება 3 საათის მიმართულებით +cameraRaw Show Selected Region მონიშნული რეგიონის ჩვენება +cameraRaw Show Skin Tone Indicator კანის ტონის მაჩვენებლის ჩვენება +cameraRaw Show Vectorscope ვექტორსკოპის ჩვენება +cameraRaw Tone Histogram ტონების ჰისტოგრამა +cameraRaw Vignetting ვინიეტირება +cameraRaw White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია + No matching fonts შესაბამისი შრიფტები არ არის From eb6cf17f5a2ff9e1577b2af2499054bc58a0d700 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:05:00 +0000 Subject: [PATCH 095/310] #10726: knots carried on don't toggle; the target test keeps its selection; reversed and committed joins tested Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 41 ++++++++++++++++++- .../ui-egui/src/tools/vector_ui/curvature.rs | 7 +++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 0ac2d332a2..14fdcbfb1b 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -308,6 +308,43 @@ fn a_smooth_endpoint_carried_on_stays_smooth() { let (a, b) = ([anchor[0] - in_ctrl[0], anchor[1] - in_ctrl[1]], [out_ctrl[0] - anchor[0], out_ctrl[1] - anchor[1]]); assert!(b[0].hypot(b[1]) > 1.0, "the outgoing handle bends into the new segment: {out_ctrl:?}"); assert!((a[0] * b[1] - a[1] * b[0]).abs() < 1e-6 && a[0] * b[0] + a[1] * b[1] > 0.0, "in line: {knots:?}"); + // CodeRabbit on #10744: committed, the join is stored as a smooth knot. + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + assert!(work.subpaths[0].knots[1].smooth, "{:?}", work.subpaths[0].knots[1]); +} + +/// CodeRabbit on #10744: a curved path carried on from its first endpoint runs backwards, each +/// knot's handles swapped, and that endpoint keeps the handle it had towards the path. +#[test] +fn a_curved_path_carries_on_from_its_first_endpoint_with_its_handles_swapped() { + use photocraft_doc::vector::{Knot, Subpath}; + use photocraft_geom::Point; + let mut app = app(Tool::CurvaturePen); + let start = Knot::smooth(Point::new(20.0, 20.0), Point::new(10.0, 30.0), Point::new(30.0, 10.0)); + set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![start, Knot::corner(80.0, 80.0)], op: Default::default() }]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 0]); + click(&mut app, 0.0, 100.0); + let pen = app.ui.pen.as_ref().expect("the path is carried on"); + assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(0)); + let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); + assert_eq!(anchors, [[80.0, 80.0], [20.0, 20.0], [0.0, 100.0]], "backwards, then the new anchor"); + assert_eq!(pen.knots[1][1], [30.0, 10.0], "the endpoint's handle towards the path: its old outgoing one"); +} + +/// CodeRabbit on #10744: a double-click on a knot of the path carried on (not the endpoint it +/// carries on from) changes nothing: those knots keep their handles. +#[test] +fn toggling_a_carried_knot_changes_nothing() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 2]); + click(&mut app, 140.0, 120.0); + let before = app.ui.pen.as_ref().map(|p| p.unlinked.clone()); + crate::vector_ui::curvature_toggle(&mut app, 80.0, 20.0); + assert_eq!(app.ui.pen.as_ref().map(|p| p.unlinked.clone()), before, "the old corner stays as it was"); } /// CodeRabbit on #10744: in Path mode the Pen carries on the work path, so an endpoint selected @@ -326,8 +363,10 @@ fn an_endpoint_on_another_path_than_the_pen_draws_on_starts_a_new_one() { // The Paths panel on no path: the Pen draws on the work path, not the layer's. app.ui.selected_path = None; set_work_path(&mut app, vec![Subpath::polyline(&[(200.0, 20.0), (80.0, 80.0)])]); - select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); + // The tool first: switching tools drops a Direct Selection selection. app.ui.tool = Tool::CurvaturePen; + select_anchor(&mut app, crate::direct_select::PathRef::Layer(shape), [0, 1]); + assert!(crate::direct_select::selected_open_end(&app).is_some(), "the shape's endpoint is selected"); click(&mut app, 140.0, 150.0); let pen = app.ui.pen.as_ref().expect("a new path"); assert!(pen.resumes.is_none() && pen.knots.len() == 1, "{pen:?}"); diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index 86951ab4b2..9137459d6b 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -15,7 +15,7 @@ pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { for (i, was) in pen.fixed.iter().enumerate() { let Some(k) = pen.knots.get_mut(i) else { break }; let (keep_in, keep_out) = (!(closed && i == 0), !(extended && i == last)); - let smooth = !pen.unlinked.contains(&i); + let smooth = !corners.get(i).copied().unwrap_or(false); k[0] = was[0]; if keep_in { k[1] = was[1]; @@ -135,6 +135,11 @@ pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { cancel_stale_pen(app); let Some(i) = curvature_hit(app, x, y) else { return }; let Some(pen) = app.ui.pen.as_mut() else { return }; + // The knots of a path carried on keep their handles (`curvature_refresh`), but for the + // endpoint drawing carries on from: toggling one of the others would change nothing. + if i + 1 < pen.fixed.len() { + return; + } if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { pen.unlinked.remove(at); } else { 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 096/310] 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 33175a15be..a5de7bbc68 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 831258b315..f296469655 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 176fb970fd..caad46edc1 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 6137b12466..2f8c3628d7 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 a0821f154e..c586cb0b60 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 097/310] 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 ab0417b79b..0fab1b2a97 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 94e04b518a..19ef8bfa92 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 a5de7bbc68..4ba08fbaae 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 caad46edc1..cffeed0644 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 2f8c3628d7..c7278455ef 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 c586cb0b60..3fcbc6eb84 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 098/310] 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 cffeed0644..f830c25dc7 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 099/310] 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 d0be63bf5e..5e5eaa3b3c 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 4b22515c09..71d00c1159 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 0000000000..520f5e0c50 --- /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 467b04720d..c5fe4e7569 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 d84d6d96e5..2642773f20 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 0000000000..dcf1aa6837 --- /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 99f883fc98..12860ed3d0 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 355cade6a3fd0389c4b4d61a013cd3a2c28c8f0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:22:57 +0000 Subject: [PATCH 100/310] #10726: a smooth corner endpoint's bent handle follows the way its segment arrives; carried joins tested further Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 34 +++++++++++++++---- .../ui-egui/src/tools/vector_ui/curvature.rs | 8 ++++- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 14fdcbfb1b..399d21e10d 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -312,7 +312,10 @@ fn a_smooth_endpoint_carried_on_stays_smooth() { crate::vector_ui::pen_commit(&mut app, false); let work = work_path(&app); assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); - assert!(work.subpaths[0].knots[1].smooth, "{:?}", work.subpaths[0].knots[1]); + let join = &work.subpaths[0].knots[1]; + assert!(join.smooth, "{join:?}"); + let (a, b) = ([join.anchor.x - join.in_ctrl.x, join.anchor.y - join.in_ctrl.y], [join.out_ctrl.x - join.anchor.x, join.out_ctrl.y - join.anchor.y]); + assert!(b[0].hypot(b[1]) > 1.0 && (a[0] * b[1] - a[1] * b[0]).abs() < 1e-6 && a[0] * b[0] + a[1] * b[1] > 0.0, "stored in line: {join:?}"); } /// CodeRabbit on #10744: a curved path carried on from its first endpoint runs backwards, each @@ -323,14 +326,16 @@ fn a_curved_path_carries_on_from_its_first_endpoint_with_its_handles_swapped() { use photocraft_geom::Point; let mut app = app(Tool::CurvaturePen); let start = Knot::smooth(Point::new(20.0, 20.0), Point::new(10.0, 30.0), Point::new(30.0, 10.0)); - set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![start, Knot::corner(80.0, 80.0)], op: Default::default() }]); + let middle = Knot::smooth(Point::new(50.0, 60.0), Point::new(40.0, 50.0), Point::new(60.0, 70.0)); + set_work_path(&mut app, vec![Subpath { closed: false, knots: vec![start, middle, Knot::corner(80.0, 80.0)], op: Default::default() }]); select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 0]); click(&mut app, 0.0, 100.0); let pen = app.ui.pen.as_ref().expect("the path is carried on"); assert_eq!(pen.resumes.as_ref().map(|r| r.index), Some(0)); let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); - assert_eq!(anchors, [[80.0, 80.0], [20.0, 20.0], [0.0, 100.0]], "backwards, then the new anchor"); - assert_eq!(pen.knots[1][1], [30.0, 10.0], "the endpoint's handle towards the path: its old outgoing one"); + assert_eq!(anchors, [[80.0, 80.0], [50.0, 60.0], [20.0, 20.0], [0.0, 100.0]], "backwards, then the new anchor"); + assert_eq!((pen.knots[1][1], pen.knots[1][2]), ([60.0, 70.0], [40.0, 50.0]), "the curved middle knot's handles are swapped"); + assert_eq!(pen.knots[2][1], [30.0, 10.0], "the endpoint's handle towards the path: its old outgoing one"); } /// CodeRabbit on #10744: a double-click on a knot of the path carried on (not the endpoint it @@ -342,9 +347,26 @@ fn toggling_a_carried_knot_changes_nothing() { set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0)])]); select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 2]); click(&mut app, 140.0, 120.0); - let before = app.ui.pen.as_ref().map(|p| p.unlinked.clone()); + let before = app.ui.pen.as_ref().map(|p| (p.unlinked.clone(), p.knots.clone())); crate::vector_ui::curvature_toggle(&mut app, 80.0, 20.0); - assert_eq!(app.ui.pen.as_ref().map(|p| p.unlinked.clone()), before, "the old corner stays as it was"); + assert_eq!(app.ui.pen.as_ref().map(|p| (p.unlinked.clone(), p.knots.clone())), before, "the old corner stays as it was, handles and all"); +} + +/// CodeRabbit on #10744: the corner endpoint carried on from, toggled smooth, has no incoming +/// handle to line up with: its outgoing handle goes on the way the straight segment arrives. +#[test] +fn a_corner_endpoint_toggled_smooth_goes_on_the_way_its_segment_arrives() { + use photocraft_doc::vector::Subpath; + let mut app = app(Tool::CurvaturePen); + set_work_path(&mut app, vec![Subpath::polyline(&[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0)])]); + select_anchor(&mut app, crate::direct_select::PathRef::Work, [0, 2]); + click(&mut app, 140.0, 120.0); + crate::vector_ui::curvature_toggle(&mut app, 80.0, 80.0); + let pen = app.ui.pen.as_ref().expect("the path is carried on"); + assert!(!pen.unlinked.contains(&2), "the endpoint is smooth now"); + let [anchor, _, out] = pen.knots[2]; + // The segment arrives straight down from (80, 20): the handle goes on straight down. + assert!((out[0] - anchor[0]).abs() < 1e-6 && out[1] > anchor[1] + 1.0, "{:?}", pen.knots[2]); } /// CodeRabbit on #10744: in Path mode the Pen carries on the work path, so an endpoint selected diff --git a/crates/ui-egui/src/tools/vector_ui/curvature.rs b/crates/ui-egui/src/tools/vector_ui/curvature.rs index 9137459d6b..13fbabd9b6 100644 --- a/crates/ui-egui/src/tools/vector_ui/curvature.rs +++ b/crates/ui-egui/src/tools/vector_ui/curvature.rs @@ -25,7 +25,13 @@ pub(super) fn curvature_refresh(pen: &mut PenPath, closed: bool) { } if smooth && keep_in != keep_out { let (kept, bent) = if keep_in { (1, 2) } else { (2, 1) }; - if let Some(h) = in_line(k[0], k[kept], k[bent]) { + // A kept handle on its anchor gives no direction: the kept segment arrives along its + // other control point, else from the neighbouring anchor, so the bent handle goes on + // that way and the join is still smooth (CodeRabbit on #10744). + let across = + if keep_in { i.checked_sub(1).and_then(|j| pen.fixed.get(j)).map(|n| [n[2], n[0]]) } else { pen.fixed.get(i + 1).map(|n| [n[1], n[0]]) }; + let toward = std::iter::once(k[kept]).chain(across.into_iter().flatten()); + if let Some(h) = toward.filter_map(|from| in_line(k[0], from, k[bent])).next() { k[bent] = h; } } From 7e9438e3671e7339f0d15388048adac1d80961ae Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Tue, 3 Nov 2026 16:49:32 +0400 Subject: [PATCH 101/310] i18n(ka): keep theme names Studio / Studio light in Latin, like the other themes Spotted by @supersfera-online. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2e6fec24fb..4f9932c4b1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1937,8 +1937,8 @@ Snap vector tools and transforms to pixel grid ვექტორული ხელსაწყოებისა და გარდაქმნების პიქსელურ ბადეზე მიბმა Srgb sRGB Standard სტანდარტული - Studio სტუდია - Studio light სტუდია ღია + Studio Studio + Studio light Studio ღია Adwaita Adwaita Adwaita dark Adwaita ბნელი Subdivisions ქვედანაყოფები From 41b78106273fd30fffda276fb681d7ee568835c1 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 05:33:43 -0800 Subject: [PATCH 102/310] 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 5e5eaa3b3c..f674f22f54 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 0000000000..b85b68504a --- /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 f8e4da4f54..ef13d5ad15 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 307043b592..0971bd0d86 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 ae1b880a64..cdd4af9905 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 0000000000..6a5cd727f1 --- /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 c5fe4e7569..7a2695e053 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 12860ed3d0..4bf73ec644 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 103/310] 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 f674f22f54..94d7528204 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 b85b68504a..7e1ef82875 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 71d00c1159..57ee26a2e5 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 0000000000..26541ed288 --- /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 6a5cd727f1..3ff4b383f3 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 7a2695e053..09ebe260fd 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 4bf73ec644..8ffb9f9854 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 b635eb0da31b9553b57fa47fac58fce0edec3473 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Tue, 3 Nov 2026 17:52:24 +0400 Subject: [PATCH 104/310] i18n(ka): add 17 reviewed rows other catalogs gained Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.tsv | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4f9932c4b1..915f6334f3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4638,3 +4638,20 @@ cameraRaw Tone Histogram ტონების ჰისტოგრამა cameraRaw Vignetting ვინიეტირება cameraRaw White Balance: As Shot თეთრის ბალანსი: როგორც გადაღებულია No matching fonts შესაბამისი შრიფტები არ არის + Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. არასწორი რიცხვითი მნიშვნელობა. საჭიროა მთელი რიცხვი {min}%-დან {max}%-მდე. ჩასმულია წინა მნიშვნელობა. + Recently Used Colors ბოლოს გამოყენებული ფერები + Shapes can't use patterns yet ფიგურებს პატერნების გამოყენება ჯერ არ შეუძლიათ + Stop Color გადასვლის წერტილის ფერი + {set} from {file} replaced the one from {earlier} {file}-დან {set}-მა ჩაანაცვლა {earlier}-დან ჩატვირთული +@plural {n} action file loaded|{n} action files loaded ჩატვირთულია მოქმედებების ფაილი: {n} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} ვერ ჩაიტვირთა ფაილი: {n} — {errors} +smartSvg AI/PDF/EPS contents will be edited as SVG. Saving replaces the embedded source; format-specific features may change. AI/PDF/EPS შიგთავსი SVG-ის სახით დარედაქტირდება. შენახვა ჩაანაცვლებს ჩაშენებულ წყაროს; ფორმატისთვის დამახასიათებელი შესაძლებლობები შეიძლება შეიცვალოს. +smartSvg Choose executable… შესრულებადი ფაილის არჩევა… +smartSvg Choose the application used to edit this SVG: აირჩიეთ აპლიკაცია, რომლითაც ეს SVG დარედაქტირდება: +smartSvg Edit SVG Smart Object SVG ჭკვიანი ობიექტის რედაქტირება +smartSvg Other application სხვა აპლიკაცია +smartSvg PhotoCraft (internal editor) PhotoCraft (ჩაშენებული რედაქტორი) +smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-რედაქტორი გაიხსნა. ამ ჭკვიანი ობიექტის განსაახლებლად შეინახეთ SVG. +smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. შეინახეთ გახსნილ SVG ფაილში. ყოველი შენახული ცვლილება PhotoCraft-ში ერთ დაბრუნების ნაბიჯად იქცევა. +smartSvg Stop syncing this Smart Object ამ ჭკვიანი ობიექტის სინქრონიზაციის შეწყვეტა +smartSvg Updates wait while another document or an editing dialog is active. განახლება დაელოდება, სანამ სხვა დოკუმენტი ან რედაქტირების დიალოგი აქტიურია. From b15a6ec6518c2081901f491d843c54739556cb37 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Tue, 3 Nov 2026 17:55:11 +0400 Subject: [PATCH 105/310] i18n(ka): translate memory percent, full-size composite and scratch size limit prefs Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 915f6334f3..619b8085b6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4655,3 +4655,6 @@ smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-რე smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. შეინახეთ გახსნილ SVG ფაილში. ყოველი შენახული ცვლილება PhotoCraft-ში ერთ დაბრუნების ნაბიჯად იქცევა. smartSvg Stop syncing this Smart Object ამ ჭკვიანი ობიექტის სინქრონიზაციის შეწყვეტა smartSvg Updates wait while another document or an editing dialog is active. განახლება დაელოდება, სანამ სხვა დოკუმენტი ან რედაქტირების დიალოგი აქტიურია. + Memory usage percent მეხსიერების გამოყენება (%) + Save Full-Size Composite in .pcraft Files სრული ზომის გაერთიანებული გამოსახულების შენახვა .pcraft ფაილებში + Scratch Size Limit (MB, 0 = No Limit) დროებითი დისკის ზომის ლიმიტი (მბ, 0 = ლიმიტის გარეშე) From 0247c23b4319d75fe91a5ac16c9599f6fd73453d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 07:03:38 -0800 Subject: [PATCH 106/310] 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 94d7528204..a4bf85c0de 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 7e1ef82875..b629e4032a 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 0000000000..d4a276471d --- /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 cdd4af9905..c7a6bccfa8 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 d7916fe548..42e01d6f38 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 0000000000..79f5915756 --- /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 09ebe260fd..a5428b22ee 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 0e527bf908..fdbb4cae3d 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 0f87c26faf..3d87a0ea72 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 2642773f20..58a2a77eab 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 0000000000..673b75a090 --- /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 8ffb9f9854..906576d0d0 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 dd8d67564642e7979ba463fc6fb97587ec7f4dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 18:33:25 +0000 Subject: [PATCH 107/310] #10726: the committed-join test checks the commit went through and carried the path on Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/pen_variants_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index 399d21e10d..c05e82d858 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -309,9 +309,10 @@ fn a_smooth_endpoint_carried_on_stays_smooth() { assert!(b[0].hypot(b[1]) > 1.0, "the outgoing handle bends into the new segment: {out_ctrl:?}"); assert!((a[0] * b[1] - a[1] * b[0]).abs() < 1e-6 && a[0] * b[0] + a[1] * b[1] > 0.0, "in line: {knots:?}"); // CodeRabbit on #10744: committed, the join is stored as a smooth knot. - crate::vector_ui::pen_commit(&mut app, false); + assert!(crate::vector_ui::pen_commit(&mut app, false), "the commit goes through"); let work = work_path(&app); assert_eq!(work.subpaths.len(), 1, "the same subpath, extended"); + assert_eq!(work.subpaths[0].knots.len(), 3, "carried on by the new anchor"); let join = &work.subpaths[0].knots[1]; assert!(join.smooth, "{join:?}"); let (a, b) = ([join.anchor.x - join.in_ctrl.x, join.anchor.y - join.in_ctrl.y], [join.out_ctrl.x - join.anchor.x, join.out_ctrl.y - join.anchor.y]); From ec9c5abb85095dd2775a0d6ab79c3cde86c33cb1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 11:48:15 -0800 Subject: [PATCH 108/310] Tests: a paused Equalize step runs on the entire image when chosen equalize_step_with_a_selection_opens_its_choices now confirms the paused step's dialog with selectedAreaOnly set to false and checks that a pixel outside the selection changes. --- crates/ui-egui/src/panels/actions/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index f8200895af..8e9f7afea8 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1431,16 +1431,30 @@ fn equalize_step_with_a_selection_opens_its_choices() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": [0.4, 0.4, 0.4]})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 8, "width": 16, "height": 8})).unwrap(); + app.run("edit.fill", json!({"color": [0.5, 0.5, 0.5]})).unwrap(); app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); let mut action = Action::new("Equalize", vec![(crate::equalize_ui::COMMAND.into(), json!({}))]); action.dialogs.insert(0); app.session.actions.list.push(action); + let gray = |app: &PhotocraftApp, x: i32, y: i32| { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[0] + }; let result = play(&mut app, &json!({"action": "Equalize"})).unwrap(); let id = result["paused"]["dialog"].as_u64().unwrap(); let fields = &app.ui.dialog_mut(id).unwrap().fields; assert!(crate::equalize_ui::owns(fields)); assert_eq!(fields["selectedAreaOnly"], true); + assert!((gray(&app, 12, 1) - 0.4).abs() < 0.01, "nothing changes before OK"); + + // OK with the entire image chosen maps the pixels outside the selection too. + app.ui.dialog_mut(id).unwrap().fields.insert("selectedAreaOnly".into(), json!(false)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(gray(&app, 12, 1) < 0.05 && gray(&app, 12, 12) > 0.95, "outside the selection too"); } /// An 8×6 document, a Child action whose first step (a blur) shows its dialog and whose From 00d1fc33d5d09b5d469667d9e69b840a3d416ad2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 13:07:40 -0800 Subject: [PATCH 109/310] Adjustments: Equalize's dialog refuses OK once its selection is cleared A control command can clear the selection while the dialog is open. OK then ran Equalize on the whole layer under a choice that names a selection. The dialog stays open until the selection is back or the dialog is canceled. --- crates/ui-egui/src/dialogs/equalize_ui.rs | 7 +++++-- crates/ui-egui/src/dialogs/equalize_ui/tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/equalize_ui.rs b/crates/ui-egui/src/dialogs/equalize_ui.rs index f1fe333476..bf68d9af7e 100644 --- a/crates/ui-egui/src/dialogs/equalize_ui.rs +++ b/crates/ui-egui/src/dialogs/equalize_ui.rs @@ -35,13 +35,16 @@ pub fn open(app: &mut PhotocraftApp) -> u64 { app.ui.open_dialog(DialogKind::Command, f) } -/// OK runs only while the document the dialog was opened for is active; otherwise the dialog -/// stays open. +/// OK runs only while the document the dialog was opened for is active and still has a selection; +/// otherwise the dialog stays open. pub fn validate(app: &PhotocraftApp, f: &Map) -> Result<(), String> { let st = app.session.active().ok_or("no document")?; if f.get("__document") != Some(&json!(st.doc.id)) { return Err("the Equalize dialog belongs to another document".into()); } + if st.doc.selection.is_none() { + return Err("the selection was cleared while the Equalize dialog was open".into()); + } Ok(()) } diff --git a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs index 645aa6fb25..3e666d506a 100644 --- a/crates/ui-egui/src/dialogs/equalize_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/equalize_ui/tests.rs @@ -85,6 +85,18 @@ fn ok_refuses_while_another_document_is_active() { assert!(gray(&app, 1, 1) < 0.05, "OK runs once its document is active again"); } +/// OK on a dialog whose selection was cleared while it was open equalizes nothing and leaves the +/// dialog open, so the choice is never applied to the whole layer. +#[test] +fn ok_refuses_after_the_selection_is_cleared() { + let (mut app, ctx) = setup(true); + let d = menu(&mut app, &ctx, COMMAND, json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.run("select.deselect", json!({})).unwrap(); + assert!(crate::dialogs::confirm(&mut app, d).is_err()); + assert!(app.ui.dialogs.iter().any(|x| x.id == d), "the dialog stays open"); + assert!((gray(&app, 1, 1) - 0.4).abs() < 0.01 && (gray(&app, 30, 20) - 0.5).abs() < 0.01, "the layer is unchanged"); +} + #[test] fn cancel_leaves_the_layer_unchanged() { let (mut app, ctx) = setup(true); From 32f1af86c80f55e525b4491396b5e491b7d9186d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Tue, 3 Nov 2026 14:42:54 -0800 Subject: [PATCH 110/310] 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 700bccb19f..82ea7e9c3e 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 0000000000..5e77b86582 --- /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 0000000000..1570454d55 --- /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 0000000000..0348d1a5ea --- /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 0000000000..0f0b64d78d --- /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 0000000000..717ce4ad59 --- /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 0000000000..2b2484228b --- /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 0000000000..3efbd65573 --- /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 0000000000..d8692610a3 --- /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 0000000000..8c3badb49f --- /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 0000000000..7ab552f157 --- /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 0000000000..4935d7b3a8 --- /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 0000000000..bf088f2d0e --- /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 0000000000..0fc080b036 --- /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 0000000000..ae272e6354 --- /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 0000000000..bb090d52ce --- /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 0000000000..d9c9010232 --- /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 0000000000..148897245d --- /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 0000000000..9a3032805a --- /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 0000000000..b7ac70b771 --- /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 0000000000..80931627b6 --- /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 0000000000..88061b864a --- /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 0000000000..b835f376af --- /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 0000000000..65f95b1f24 --- /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 0000000000..5941835d53 --- /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 0000000000..d894e81f98 --- /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 0000000000..ede1ca0a37 --- /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 0000000000..85d6fc133b --- /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 0000000000..aba8e45a87 --- /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 0000000000..e599c9ae7f --- /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 0000000000..0c3bdf6b93 --- /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 0000000000..92e3faabec --- /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 a4bf85c0de..32c3edb8be 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 b629e4032a..9ec48a740c 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 d4a276471d..53fc59b4a0 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 ef13d5ad15..d779b91592 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 47f4b688fb..33304ea1bb 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 57ee26a2e5..e167880c1c 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 0971bd0d86..562fa6dac4 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 0000000000..0995c1e780 --- /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 0000000000..bb749772da --- /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 c7a6bccfa8..51fd2033c0 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 a463418ef6..364238dc02 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 3b52c03a03..30449d9b3f 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 26541ed288..bdefc57cf8 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 0000000000..31907b7ced --- /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 0000000000..3aea3f3ba7 --- /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 a5428b22ee..20493de805 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 0e5e08d699..74d693a1d5 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 0000000000..53bb93b8d7 --- /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 0000000000..1430e256f9 --- /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 a90d4903ad..1d35995a7e 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 2277b6e82a..2ccef1b612 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 1a43ffe77f..420348b5e9 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 07ea17b7e8..d34c0f7302 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 53df5d373f..b612ea3393 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 fdbb4cae3d..fc93f714bc 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 41ca4e4acb..830f54a39c 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 0c67a91ec6..157d301f58 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 8cd95ed30d..ead8982b09 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 46e71ed2cf..119ffa7bd9 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 45fb326bea..2d55876f65 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 8f20dd6705..f686f2b754 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 6b365ce2be..7dc499accd 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 b08a224b27..3cd4e9a3c2 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 89bdec45d4..46dd6c3fcb 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 c5a61baed8..a521c5224a 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 e5058af2c9..8bd7b02f2e 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 367c7267f5..79cee94e0e 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 2eee5145a2..1daa190c1c 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 43f7dea83e..9cea628b3b 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 d7232004b6..4f56d00d80 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 7ad2900a28..c6475b57d6 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 fe13d8f177..3c492e1b7a 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 f12a596d1a..5bcb413524 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 60f3929e5f..bac5dd8f56 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 2ebc6c2e70..acea988929 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 b9c3721765..ec0b8d604a 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 007d756f89..77bdde71c2 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 68128b8584..3785427039 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 d868ab099a..09b87efead 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 d9b554acfa..53b157278a 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 9379418b9e..6026f454d4 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 d261e762cd..aa8753ba2f 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 0a42a03527..57d5b6d18e 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 5a9841ef60..b09b5cf343 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 6abf64fd1d..7011b05f23 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 c932ca092c..e4826a0d09 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 58a2a77eab..7ea0f87041 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 0000000000..1434fcd667 --- /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 0000000000..953819cf90 --- /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 0000000000..857d1a0087 --- /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 0000000000..9a5b4c68e4 --- /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 0000000000..05715d1fac --- /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 0000000000..a72d0ad6b7 --- /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 0000000000..03c42d4757 --- /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 e35999dedc..2d0f040c6c 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 ebddecde2c..df921de8d8 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 16b945b49e..570c142428 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 c18a418c4c..7ff3ccd047 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 2c08f63faa..4ce62d13c1 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 111/310] 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 f8b900e49a..444769336c 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 c3583bf83d..14750da19e 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 347f829443..de12940839 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 386eafe62c..be4d274361 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 23d814e601..0c6161cc3a 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 a39fdfb4b4..5822940450 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 0541f28602..9fd4a7ca07 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 0000000000..b50eeaff32 --- /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 0000000000..7486036205 --- /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 8f38b142d5..ccb42693d7 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 d779b91592..c0d506520c 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 42d64a4504..6ec7795a9f 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 e167880c1c..9b9c6515c3 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 562fa6dac4..ec2e1557e9 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 0995c1e780..e450c11513 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 bb749772da..2a5a000254 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 f468f72432..843746ee66 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 cc93556412..e430eaf294 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 7f0ead6c6b..6b2df64a0e 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 bdefc57cf8..d28732a824 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 520f5e0c50..d44eb1cf5b 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 696b52cb72..d09bc80813 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 0000000000..55acde8851 --- /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 dd3575ed15..e49b0df0fc 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 53bb93b8d7..7b5c54ff20 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 112/310] 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 e450c11513..b73f9b36c6 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 e49b0df0fc..8115650ba2 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 113/310] 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 e9a52fb430..a389e3e914 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 114/310] 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 9fd4a7ca07..6094fb2fa8 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 9ec48a740c..972a439be6 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 ccb42693d7..02cbd051e9 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 115/310] 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 9bf0495cc0..dcbaaecc33 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 116/310] 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 7e00fd8cb3..d0ad6ee752 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 26807afed6..21c14cb86f 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 dd76e446c9..6f9186164a 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 0000000000..a388f6c909 --- /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 117/310] 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 6f9186164a..781e02ec20 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 a388f6c909..397cef40c8 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 118/310] 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 d0ad6ee752..68f37fb5b9 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 397cef40c8..ca08d2f012 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 119/310] 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 ca08d2f012..cd0e818853 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 120/310] 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 68f37fb5b9..5a26a5a281 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 cd0e818853..66d13125b7 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 121/310] 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 b50eeaff32..1410da4d01 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 53fc59b4a0..8f476cc4ea 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 9b9c6515c3..1dc5afd3b2 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 ec2e1557e9..a07a809fee 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 b73f9b36c6..4401d6ed66 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 55acde8851..b43434d9bd 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 122/310] 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 02cbd051e9..dda3ebccbc 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 e4ebcd0ea0..037d57c328 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 976940ef43..d880d99d9b 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 df643b2608..9d5dd79916 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 796982207f..398683b534 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 cf1f91b6de..488cdeb491 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 8a015f1497..617339a5e6 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 8f59433b9e..7328ada0d3 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 acecf5ec7e..bd4e0d549d 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 0ff38f2036..0a88c27595 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 92a23aff5e..394126a108 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 3034fb2f0a..a64bd77197 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 c02c4061ac..bde01cbca1 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 af0f131dd8..fbbcccb4f4 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 0000000000..55c834444a --- /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 0482a5ab99..319d64ad7f 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 123/310] 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 781e02ec20..735bec0658 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 66d13125b7..010d4e68e9 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 124/310] =?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 ada040e8b7..dfbe283dfc 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 e5e361fb1c..80422ce552 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 a3c055cf7b..fbd3cd87cb 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 a089803be8..c995068197 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 0634d39918..ad3a741a4b 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 0000000000..964b76f056 --- /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 0000000000..ea1dccd5ab --- /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 4a3c55d6ec..a4e8d46bb5 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 125/310] 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 6094fb2fa8..662bf128f6 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 972a439be6..d6f91fe30c 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 1410da4d01..c841469802 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 c0d506520c..041af53e06 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 4401d6ed66..5ee23bb96e 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 31907b7ced..6ecc6632eb 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 3ff4b383f3..0379fb6d4b 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 3aea3f3ba7..52dbaa5a26 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 126/310] 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 afbda2de64..7a3f7e997e 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 07c4f89c8e..c4f4a9ab3e 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 857d1a0087..11cc8a4212 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 9a5b4c68e4..c778145f4d 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 90c5b409c6..75f5d84e9e 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 673b75a090..d81c8c1a8a 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 4ce62d13c1..73df15bf75 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 d869649853..149666444b 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 127/310] 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 444769336c..8341972c2d 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 be4d274361..6333eb575a 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 128/310] 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 157d301f58..bf0d37d884 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 ead8982b09..1927408697 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 119ffa7bd9..a30815902d 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 2d55876f65..2cb711a0b9 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 f686f2b754..b6784e4ccc 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 7dc499accd..7ad08f5559 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 3cd4e9a3c2..c0d691ab95 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 46dd6c3fcb..cd40557de8 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 a521c5224a..8e762c5e79 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 8bd7b02f2e..34d0e6c7a4 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 79cee94e0e..8bec13eed4 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 1daa190c1c..77f64f42f3 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 9cea628b3b..42f5c851d9 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 4f56d00d80..0c7593503c 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 c6475b57d6..8b26dcc1e7 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 3c492e1b7a..98926ba393 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 5bcb413524..44a9d37bb8 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 bac5dd8f56..2bbe657e8f 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 acea988929..0427e0a539 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 ec0b8d604a..4c554a2124 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 77bdde71c2..c3f7f8be3b 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 3785427039..f677f44697 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 09b87efead..269e27badb 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 53b157278a..69463b2f7a 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 6026f454d4..cca5afb4ee 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 11cc8a4212..766a92b9a5 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 c778145f4d..efbb7765a4 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 129/310] 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 0c6161cc3a..b83c0ebca1 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 0000000000..2ea0f5e9a3 --- /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 5822940450..40f030f24b 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 024888c65d..394f68ecef 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 130/310] 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 406bacf367..07d78cd5ba 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 131/310] 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 c841469802..5f4a4e7d39 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 132/310] 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 0379fb6d4b..7507d92af2 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 52dbaa5a26..7a30390955 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 c4f4a9ab3e..e7fd8a838e 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 133/310] 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 d81c8c1a8a..ed63750643 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 134/310] 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 5a26a5a281..76237b1e20 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 735bec0658..d06f921574 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 010d4e68e9..1f9b52ae1c 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 135/310] 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 964b76f056..bfacef6f1e 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 ea1dccd5ab..364bb1d6d1 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 136/310] 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 bfacef6f1e..941ebef92d 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 364bb1d6d1..076d5038c8 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 137/310] 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 ab0417b79b..3428c184f3 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 94e04b518a..c6be03640f 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 138/310] 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 d6f91fe30c..718fc4d7bd 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 5f4a4e7d39..9e1ba51ece 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 041af53e06..14ff032e02 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 6ecc6632eb..9307e9e131 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 7507d92af2..3720a797f4 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 139/310] 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 2ea0f5e9a3..1992357967 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 40f030f24b..4f542e98f1 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 5ee23bb96e..feefec560e 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 140/310] 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 766a92b9a5..63a9dd6f96 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 efbb7765a4..654f247af5 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 75f5d84e9e..8bc45ea322 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 73df15bf75..cbb8a5d090 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 149666444b..fb2cc2111f 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 141/310] 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 e7fd8a838e..b6c326e121 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 142/310] 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 b6c326e121..befd1e8f0d 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 dda67cc000..b0d006305f 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 143/310] 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 8341972c2d..a17ca7586d 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 6333eb575a..5ec18717fd 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 662bf128f6..de2c235bb5 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 718fc4d7bd..02d6a5de97 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 a07a809fee..754dd43141 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 9307e9e131..420fd62031 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 3720a797f4..6d84097ea9 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 0000000000..60cc11da01 --- /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 617339a5e6..4d164716fb 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 38db75621e..0320be9e92 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 0a88c27595..ae9a6b9ffd 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 fb6e605d4d..c0267b95cf 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 bde01cbca1..029dd85b54 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 830f54a39c..a5b860272e 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 a75a1aeb99..ffddbe98e6 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 befd1e8f0d..e422972c16 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 1434fcd667..c572c1d625 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 654f247af5..3341bc16ef 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 07d78cd5ba..85e4f0f9be 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 8bc45ea322..11cce9989d 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 144/310] 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 fb2fb9bd79..cf6a76e4e4 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 145/310] 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 b83c0ebca1..fdf9f40a13 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 1992357967..c891ada9c9 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 4f542e98f1..2df5e5b589 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 02d6a5de97..f3da70f352 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 9e1ba51ece..70ad369e1f 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 8f476cc4ea..3cb81edc37 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 1dc5afd3b2..348614c2a9 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 feefec560e..3fb2744e4d 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 c572c1d625..25388ef141 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 11cce9989d..767cb2b87e 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 146/310] 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 60cc11da01..3876cb92bd 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 147/310] 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 9c60c48675..10feff63c9 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 148/310] 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 a94cca1468..713d5a3dc8 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 149/310] 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 10feff63c9..a914b6786f 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 150/310] 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 9a3c30d4bf..73aad82f2c 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 0000000000..9cdc751463 --- /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 151/310] 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 a17ca7586d..6d9e7b7141 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 5ec18717fd..ddaa8d6252 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 5e77b86582..fa8b296077 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 9bbaacb43d..a18add81c0 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 348614c2a9..2d3535f953 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 754dd43141..00bccafcd2 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 c0c442f443..c294a9fded 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 ed1d9caabb..4ffa6047a3 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 3876cb92bd..41dcf6c968 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 0000000000..d30b19e1fc --- /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 029dd85b54..e16fa5213d 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 b43434d9bd..5e6006391c 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 f69d80915b..395f42ae82 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 6bf2125fc7..b3cfc8607c 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 a8c27700c4..a91a56cb39 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 dcbaaecc33..459164a257 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 152/310] 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 d30b19e1fc..883eadd42f 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 153/310] 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 6d9e7b7141..651aba5ba8 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 de2c235bb5..69373aec6d 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 c294a9fded..ee80dafbdc 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 154/310] 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 69373aec6d..2e59e87fc3 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 ee80dafbdc..2c2badfc90 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 41dcf6c968..34cd5013bd 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 155/310] 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 34cd5013bd..667ef0e0d1 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 156/310] 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 667ef0e0d1..2344fb3dc8 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 157/310] 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 2d3535f953..3b5b26d930 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 158/310] 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 2c2badfc90..e622daeb24 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 159/310] 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 651aba5ba8..b25c1e2a73 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 ddaa8d6252..ae291622a7 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 3b5b26d930..87f832bf20 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 00bccafcd2..1288bf5d3a 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 16e67b11c3..71616ab836 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 160/310] 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 ae291622a7..764bf1cf09 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 71616ab836..602711f1a1 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 161/310] 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 1a1231110d..2ee9abe9b1 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 2b95ab3293..827ee8de34 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 713d5a3dc8..c63d5968d2 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 162/310] 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 bca5994b6e..a6348dae00 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 163/310] 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 e02a8b10ed..f31feeab89 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 164/310] 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 3cb81edc37..d305188a55 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 120d7d232c..ef6918202f 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 a6348dae00..25b5a92a82 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 ed63750643..25e7b6ba0d 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 165/310] 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 709b7a7d90..254c47d2fc 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 bb175549b4..b7f15e2ba0 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 f5b565b849..a64ffd2a87 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 55c834444a..844920be64 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 166/310] 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 254c47d2fc..41fe944db0 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 b7f15e2ba0..a82c68d093 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 844920be64..7de8305cd9 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 25e7b6ba0d..d58725fb72 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 167/310] 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 d046f6918a..7d42e16060 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 0000000000..e74d83799d --- /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 6326d9c4ce..5672409720 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 a29b4a1957..c9ef20c96f 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 0000000000..e277e269f3 --- /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 07c71040a5..49cce4807f 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 0000000000..e753106987 --- /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 cbbbdad015..5bf4b84604 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 6fca427048..68b18e669c 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 1b3148eecd..8ecebfce88 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 d3d3479fcf..a339394f8b 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 168/310] 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 87f832bf20..9ae7f67d69 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